feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Badge } from "../UI/Badge.tsx";
|
||||
|
||||
export function SupportBadge({
|
||||
supported,
|
||||
labelSupported,
|
||||
labelUnsupported,
|
||||
}: {
|
||||
supported: boolean;
|
||||
labelSupported: string;
|
||||
labelUnsupported: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={supported ? "secondary" : "destructive"}>
|
||||
{supported ? labelSupported : labelUnsupported}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BatteryFullIcon, BatteryLowIcon, BatteryMediumIcon, PlugZapIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DeviceMetrics } from "./types.ts";
|
||||
|
||||
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
|
||||
|
||||
interface BatteryStatusProps {
|
||||
deviceMetrics?: DeviceMetrics | null;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
Icon: React.ElementType;
|
||||
className: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const BATTERY_STATUS = {
|
||||
PLUGGED_IN: "PLUGGED_IN",
|
||||
FULL: "FULL",
|
||||
MEDIUM: "MEDIUM",
|
||||
LOW: "LOW",
|
||||
} as const;
|
||||
|
||||
export const getBatteryStatus = (level: number): BatteryStatusKey => {
|
||||
if (level > 100) {
|
||||
return BATTERY_STATUS.PLUGGED_IN;
|
||||
}
|
||||
if (level > 80) {
|
||||
return BATTERY_STATUS.FULL;
|
||||
}
|
||||
if (level > 20) {
|
||||
return BATTERY_STATUS.MEDIUM;
|
||||
}
|
||||
return BATTERY_STATUS.LOW;
|
||||
};
|
||||
|
||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { batteryLevel } = deviceMetrics;
|
||||
|
||||
const statusKey = getBatteryStatus(batteryLevel);
|
||||
|
||||
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
|
||||
[BATTERY_STATUS.PLUGGED_IN]: {
|
||||
Icon: PlugZapIcon,
|
||||
className: "text-gray-500",
|
||||
text: t("batteryStatus.pluggedIn"),
|
||||
},
|
||||
[BATTERY_STATUS.FULL]: {
|
||||
Icon: BatteryFullIcon,
|
||||
className: "text-green-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
[BATTERY_STATUS.MEDIUM]: {
|
||||
Icon: BatteryMediumIcon,
|
||||
className: "text-yellow-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
[BATTERY_STATUS.LOW]: {
|
||||
Icon: BatteryLowIcon,
|
||||
className: "text-red-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
};
|
||||
|
||||
// 3. Use the key to get the current state configuration
|
||||
const {
|
||||
Icon: BatteryIcon,
|
||||
className: iconClassName,
|
||||
text: statusText,
|
||||
} = statusConfigMap[statusKey];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<BatteryIcon size={22} className={iconClassName} />
|
||||
{statusText}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatteryStatus;
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
import { useAppStore, useDevice, useDeviceStore, useNodeDB } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCommandState } from "cmdk";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxSelectIcon,
|
||||
BugIcon,
|
||||
CloudOff,
|
||||
EraserIcon,
|
||||
FactoryIcon,
|
||||
HardDriveUpload,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
Pin,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
commands: Command[];
|
||||
}
|
||||
export interface Command {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
action?: () => void;
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
}
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette = () => {
|
||||
const { commandPaletteOpen, setCommandPaletteOpen, setConnectDialogOpen, setSelectedDevice } =
|
||||
useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
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.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 nodeNum={device.hardware.myNodeNum} />,
|
||||
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.dfuMode"),
|
||||
icon: HardDriveUpload,
|
||||
action() {
|
||||
connection?.enterDfuMode();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("contextual.command.resetNodeDb"),
|
||||
icon: TrashIcon,
|
||||
action() {
|
||||
setDialogOpen("resetNodeDb", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
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() {
|
||||
setDialogOpen("factoryResetDevice", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("contextual.command.factoryResetConfig"),
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
setDialogOpen("factoryResetConfig", true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("debug.command.clearAllStores"),
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
setDialogOpen("clearAllStores", 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;
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
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: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => void;
|
||||
}) => {
|
||||
const search = useCommandState((state) => state.search);
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandItem onSelect={action}>
|
||||
{icon}
|
||||
{label}
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronRight,
|
||||
CpuIcon,
|
||||
Languages,
|
||||
type LucideIcon,
|
||||
Palette,
|
||||
Search as SearchIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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;
|
||||
deviceMetrics: DeviceMetrics;
|
||||
firmwareVersion: string;
|
||||
user?: Protobuf.Mesh.User;
|
||||
setDialogOpen: () => void;
|
||||
setCommandPaletteOpen: () => void;
|
||||
disableHover?: boolean;
|
||||
connectionStatus?: ConnectionStatus;
|
||||
connectionName?: string;
|
||||
}
|
||||
|
||||
interface InfoDisplayItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
customComponent?: React.ReactNode;
|
||||
value?: string | number | null;
|
||||
}
|
||||
|
||||
interface ActionButtonConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
render?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const DeviceInfoPanel = ({
|
||||
deviceMetrics,
|
||||
firmwareVersion,
|
||||
user,
|
||||
isCollapsed,
|
||||
setCommandPaletteOpen,
|
||||
disableHover = false,
|
||||
connectionStatus,
|
||||
connectionName,
|
||||
}: DeviceInfoPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const { batteryLevel, voltage } = deviceMetrics;
|
||||
|
||||
const getStatusColor = (status?: ConnectionStatus): string => {
|
||||
if (!status) {
|
||||
return "bg-gray-400";
|
||||
}
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "configured":
|
||||
case "online":
|
||||
return "bg-emerald-500";
|
||||
case "connecting":
|
||||
case "configuring":
|
||||
case "disconnecting":
|
||||
return "bg-amber-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status?: ConnectionStatus): string => {
|
||||
if (!status) {
|
||||
return t("unknown.notAvailable", "N/A");
|
||||
}
|
||||
// Show "connected" for configured state
|
||||
if (status === "configured") {
|
||||
return t("toasts.connected", { ns: "connections" });
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const deviceInfoItems: InfoDisplayItem[] = [
|
||||
{
|
||||
id: "battery",
|
||||
label: t("batteryStatus.title"),
|
||||
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
|
||||
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
|
||||
},
|
||||
{
|
||||
id: "voltage",
|
||||
label: t("batteryVoltage.title"),
|
||||
icon: ZapIcon,
|
||||
value:
|
||||
voltage !== undefined ? `${voltage?.toPrecision(3)} V` : t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
label: t("sidebar.deviceInfo.firmware.title"),
|
||||
icon: CpuIcon,
|
||||
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
];
|
||||
|
||||
const actionButtons: ActionButtonConfig[] = [
|
||||
{
|
||||
id: "theme",
|
||||
label: t("theme.changeTheme"),
|
||||
icon: Palette,
|
||||
render: () => <ThemeSwitcher />,
|
||||
},
|
||||
{
|
||||
id: "commandMenu",
|
||||
label: t("page.title", { ns: "commandPalette" }),
|
||||
icon: SearchIcon,
|
||||
onClick: setCommandPaletteOpen,
|
||||
},
|
||||
|
||||
{
|
||||
id: "language",
|
||||
label: t("languagePicker.label"),
|
||||
icon: Languages,
|
||||
render: () => <LanguageSwitcher />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{user && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
nodeNum={parseInt(user.id.slice(1), 16)}
|
||||
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||
size="sm"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||
"transition-opacity duration-300 ease-in-out truncate",
|
||||
)}
|
||||
>
|
||||
{user.longName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/connections" })}
|
||||
aria-label={t("navigation.manageConnections", "Manage connections")}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 px-1 py-2 flex-shrink-0 rounded-md w-full",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||
"focus:outline-none focus:ring-2 focus:ring-accent",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 ml-2 rounded-full flex-shrink-0",
|
||||
getStatusColor(connectionStatus),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="flex flex-col min-w-0 flex-1 text-left">
|
||||
<Subtle className="text-xs truncate transition-colors duration-150 group-hover:text-gray-800 dark:group-hover:text-gray-100">
|
||||
{connectionName || "Connection"}
|
||||
</Subtle>
|
||||
<Subtle className="text-xs capitalize text-gray-500 dark:text-gray-400 transition-colors duration-150">
|
||||
{getStatusLabel(connectionStatus)}
|
||||
</Subtle>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="flex-shrink-0 text-gray-400 dark:text-gray-500 transition-colors duration-150 group-hover:text-gray-600 dark:group-hover:text-gray-300"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isCollapsed
|
||||
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||
: "opacity-100 max-w-xs h-auto visible",
|
||||
)}
|
||||
>
|
||||
{deviceInfoItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2.5 text-sm">
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={16}
|
||||
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{item.customComponent}
|
||||
{item.id !== "battery" && (
|
||||
<Subtle className="text-gray-600 dark:text-gray-300">
|
||||
{item.label}: {item.value}
|
||||
</Subtle>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && <div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 mt-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isCollapsed
|
||||
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||
: "opacity-100 max-w-xs visible",
|
||||
)}
|
||||
>
|
||||
{actionButtons.map((buttonItem) => {
|
||||
const Icon = buttonItem.icon;
|
||||
if (buttonItem.render) {
|
||||
return <Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={buttonItem.id}
|
||||
variant="ghost"
|
||||
aria-label={buttonItem.label}
|
||||
onClick={buttonItem.onClick}
|
||||
className={cn(
|
||||
"group",
|
||||
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
|
||||
"gap-2.5",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-4",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
)}
|
||||
/>
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm",
|
||||
"text-gray-600 dark:text-gray-300",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{buttonItem.label}
|
||||
</Subtle>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,604 @@
|
||||
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
||||
import { Switch } from "@app/components/UI/Switch.tsx";
|
||||
import type { ConnectionType, NewConnection } from "@app/core/stores/deviceStore/types.ts";
|
||||
import { testHttpReachable } from "@app/pages/Connections/utils";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "@core/hooks/useBrowserFeatureDetection.ts";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bluetooth,
|
||||
Cable,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
MousePointerClick,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
import { urlOrIpv4Schema } from "./validation.ts";
|
||||
|
||||
type TabKey = ConnectionType;
|
||||
type TestingStatus = "idle" | "testing" | "success" | "failure";
|
||||
type DialogState = {
|
||||
tab: TabKey;
|
||||
name: string;
|
||||
protocol: "http" | "https";
|
||||
url: string;
|
||||
testStatus: TestingStatus;
|
||||
btSelected: { id: string; name?: string; device?: BluetoothDevice } | undefined;
|
||||
serialSelected: { vendorId?: number; productId?: number } | undefined;
|
||||
};
|
||||
|
||||
type DialogAction =
|
||||
| { type: "RESET"; payload?: { isHTTPS?: boolean } }
|
||||
| { type: "SET_TAB"; payload: TabKey }
|
||||
| { type: "SET_NAME"; payload: string }
|
||||
| { type: "SET_PROTOCOL"; payload: "http" | "https" }
|
||||
| { type: "SET_URL"; payload: string }
|
||||
| { type: "SET_TEST_STATUS"; payload: TestingStatus }
|
||||
| {
|
||||
type: "SET_BT_SELECTED";
|
||||
payload: { id: string; name?: string; device?: BluetoothDevice } | undefined;
|
||||
}
|
||||
| {
|
||||
type: "SET_SERIAL_SELECTED";
|
||||
payload: { vendorId?: number; productId?: number } | undefined;
|
||||
}
|
||||
| { type: "SET_URL_AND_RESET_TEST"; payload: string };
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
tabId: "bluetooth" | "serial";
|
||||
}
|
||||
|
||||
type Pane = {
|
||||
children: () => React.ReactNode;
|
||||
placeholder: string;
|
||||
validate: () => boolean;
|
||||
build: () => NewConnection | null;
|
||||
};
|
||||
|
||||
const featureErrors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
|
||||
"Web Bluetooth": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
i18nKey: "addConnection.validation.requiresWebBluetooth",
|
||||
},
|
||||
"Web Serial": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
i18nKey: "addConnection.validation.requiresWebSerial",
|
||||
},
|
||||
"Secure Context": {
|
||||
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
i18nKey: "addConnection.validation.requiresSecureContext",
|
||||
},
|
||||
};
|
||||
|
||||
const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const needsFeature =
|
||||
tabId === "bluetooth" && browserFeatures.includes("Web Bluetooth")
|
||||
? "Web Bluetooth"
|
||||
: 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 mt-4">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-white">
|
||||
{needsFeature && (
|
||||
<Trans
|
||||
i18nKey={featureErrors[needsFeature].i18nKey}
|
||||
components={[
|
||||
<Link
|
||||
key="0"
|
||||
href={featureErrors[needsFeature].href}
|
||||
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{needsFeature && needsSecureContext && " "}
|
||||
{needsSecureContext && (
|
||||
<Trans
|
||||
i18nKey={
|
||||
browserFeatures.length > 0
|
||||
? "addConnection.validation.additionallyRequiresSecureContext"
|
||||
: "addConnection.validation.requiresSecureContext"
|
||||
}
|
||||
components={{
|
||||
"0": (
|
||||
<Link
|
||||
href={featureErrors["Secure Context"].href}
|
||||
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const initialState: DialogState = {
|
||||
tab: "http",
|
||||
name: "",
|
||||
protocol: "http",
|
||||
url: "",
|
||||
testStatus: "idle",
|
||||
btSelected: undefined,
|
||||
serialSelected: undefined,
|
||||
};
|
||||
export const createInitialDialogState = (overrides?: Partial<DialogState>): DialogState => {
|
||||
return { ...initialState, ...(overrides ?? {}) };
|
||||
};
|
||||
|
||||
export const dialogStateInitializer = (overrides?: Partial<DialogState>): DialogState =>
|
||||
createInitialDialogState(overrides);
|
||||
|
||||
function dialogReducer(state: DialogState, action: DialogAction): DialogState {
|
||||
switch (action.type) {
|
||||
case "RESET":
|
||||
return createInitialDialogState(action.payload?.isHTTPS ? { protocol: "https" } : {});
|
||||
case "SET_TAB":
|
||||
return { ...state, tab: action.payload };
|
||||
case "SET_NAME":
|
||||
return { ...state, name: action.payload };
|
||||
case "SET_PROTOCOL":
|
||||
return { ...state, protocol: action.payload };
|
||||
case "SET_URL":
|
||||
return { ...state, url: action.payload };
|
||||
case "SET_TEST_STATUS":
|
||||
return { ...state, testStatus: action.payload };
|
||||
case "SET_BT_SELECTED":
|
||||
return { ...state, btSelected: action.payload };
|
||||
case "SET_SERIAL_SELECTED":
|
||||
return { ...state, serialSelected: action.payload };
|
||||
case "SET_URL_AND_RESET_TEST":
|
||||
return { ...state, url: action.payload, testStatus: "idle" };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function PickerRow({
|
||||
label,
|
||||
buttonText,
|
||||
onPick,
|
||||
disabled,
|
||||
display,
|
||||
helper,
|
||||
}: {
|
||||
label: string;
|
||||
buttonText: string;
|
||||
onPick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
display: string;
|
||||
helper?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="subtle" className="gap-2" onClick={onPick} disabled={disabled}>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">{display}</div>
|
||||
</div>
|
||||
{helper ? <p className="text-xs text-slate-500 dark:text-slate-400">{helper}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_META: Array<{ key: TabKey; label: string; Icon: LucideIcon }> = [
|
||||
{ key: "http", label: "HTTP", Icon: Globe },
|
||||
{ key: "bluetooth", label: "Bluetooth", Icon: Bluetooth },
|
||||
{ key: "serial", label: "Serial", Icon: Cable },
|
||||
];
|
||||
|
||||
export default function AddConnectionDialog({
|
||||
open = false,
|
||||
onOpenChange = () => {},
|
||||
onSave = async () => {},
|
||||
isHTTPS = false,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSave?: (conn: NewConnection, device?: BluetoothDevice) => Promise<void>;
|
||||
isHTTPS?: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [state, dispatch] = useReducer(dialogReducer, initialState, () =>
|
||||
dialogStateInitializer(isHTTPS ? { protocol: "https" } : {}),
|
||||
);
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const bluetoothSupported = typeof navigator !== "undefined" && "bluetooth" in navigator;
|
||||
const serialSupported = typeof navigator !== "undefined" && "serial" in navigator;
|
||||
const isURLHTTPS = isHTTPS;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch({ type: "RESET", payload: { isHTTPS } });
|
||||
}, [isHTTPS]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
reset();
|
||||
}
|
||||
}, [reset, open]);
|
||||
|
||||
const makeToastErrorHandler = useCallback(
|
||||
(titlePrefix: string) =>
|
||||
(err: unknown): void => {
|
||||
if (err && (err as { name?: string }).name === "NotFoundError") {
|
||||
return; // user canceled
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast({ title: `${titlePrefix} error`, description: message });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const handlePickBluetooth = useCallback(async () => {
|
||||
if (!bluetoothSupported) {
|
||||
toast({
|
||||
title: t("addConnection.bluetoothConnection.notSupported.title"),
|
||||
description: t("addConnection.bluetoothConnection.notSupported.description"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
|
||||
});
|
||||
dispatch({
|
||||
type: "SET_BT_SELECTED",
|
||||
payload: { id: device.id, name: device.name ?? undefined, device },
|
||||
});
|
||||
if (!state.name || state.name === "") {
|
||||
dispatch({
|
||||
type: "SET_NAME",
|
||||
payload: device.name
|
||||
? t("addConnection.bluetoothConnection.short", {
|
||||
deviceName: device.name,
|
||||
})
|
||||
: t("addConnection.bluetoothConnection.long"),
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: t("addConnection.bluetoothConnection.selected"),
|
||||
description: device.name || device.id,
|
||||
});
|
||||
} catch (err) {
|
||||
makeToastErrorHandler("Bluetooth")(err);
|
||||
}
|
||||
}, [bluetoothSupported, state.name, toast, makeToastErrorHandler, t]);
|
||||
|
||||
const handlePickSerial = useCallback(async () => {
|
||||
if (!serialSupported) {
|
||||
toast({
|
||||
title: t("addConnection.serialConnection.notSupported.title"),
|
||||
description: t("addConnection.serialConnection.notSupported.description"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const port = await (
|
||||
navigator as Navigator & {
|
||||
serial: {
|
||||
requestPort: (o: Record<string, unknown>) => Promise<SerialPort>;
|
||||
};
|
||||
}
|
||||
).serial.requestPort({});
|
||||
const info =
|
||||
(
|
||||
port as SerialPort & {
|
||||
getInfo?: () => { usbVendorId?: number; usbProductId?: number };
|
||||
}
|
||||
).getInfo?.() ?? {};
|
||||
dispatch({
|
||||
type: "SET_SERIAL_SELECTED",
|
||||
payload: {
|
||||
vendorId: info.usbVendorId,
|
||||
productId: info.usbProductId,
|
||||
},
|
||||
});
|
||||
if (!state.name || state.name === "") {
|
||||
const v = info.usbVendorId ? info.usbVendorId.toString(16) : "?";
|
||||
const p = info.usbProductId ? info.usbProductId.toString(16) : "?";
|
||||
dispatch({ type: "SET_NAME", payload: `Serial: ${v}:${p}` });
|
||||
}
|
||||
toast({
|
||||
title: t("addConnection.serialConnection.portSelected.title"),
|
||||
description: t("addConnection.serialConnection.portSelected.description"),
|
||||
});
|
||||
} catch (err) {
|
||||
makeToastErrorHandler("Serial")(err);
|
||||
}
|
||||
}, [serialSupported, state.name, toast, makeToastErrorHandler, t]);
|
||||
|
||||
const handleTestHttp = useCallback(async () => {
|
||||
const fullUrl = `${state.protocol}://${state.url}`;
|
||||
const validatedURL = urlOrIpv4Schema.safeParse(fullUrl);
|
||||
if (validatedURL.success === false) {
|
||||
toast({
|
||||
title: t("addConnection.httpConnection.invalidUrl.title"),
|
||||
description: t("addConnection.httpConnection.invalidUrl.description"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "testing" });
|
||||
const reachable = await testHttpReachable(validatedURL.data);
|
||||
if (reachable) {
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
|
||||
} else {
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
|
||||
toast({
|
||||
title: t("addConnection.httpConnection.connectionTest.failure.title"),
|
||||
description: t("addConnection.httpConnection.connectionTest.failure.description"),
|
||||
});
|
||||
}
|
||||
}, [state.protocol, state.url, toast, t]);
|
||||
|
||||
const PANES: Record<TabKey, Pane> = useMemo(
|
||||
() => ({
|
||||
http: {
|
||||
placeholder: t("addConnection.httpConnection.namePlaceholder"),
|
||||
children: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Label htmlFor="url">{t("addConnection.httpConnection.heading")}</Label>
|
||||
|
||||
<Input
|
||||
id={"url"}
|
||||
inputMode="url"
|
||||
placeholder={t("addConnection.httpConnection.inputPlaceholder")}
|
||||
prefix={`${state.protocol}://`}
|
||||
value={state.url}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: "SET_URL_AND_RESET_TEST",
|
||||
payload: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Switch
|
||||
value={state.protocol}
|
||||
disabled={!!isURLHTTPS}
|
||||
checked={state.protocol === "https"}
|
||||
onCheckedChange={(value) => {
|
||||
dispatch({
|
||||
type: "SET_PROTOCOL",
|
||||
payload: value ? "https" : "http",
|
||||
});
|
||||
dispatch({ type: "SET_TEST_STATUS", payload: "idle" });
|
||||
}}
|
||||
></Switch>
|
||||
<Label>{t("addConnection.httpConnection.useHttps")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="gap-2"
|
||||
onClick={handleTestHttp}
|
||||
disabled={
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`).success === false ||
|
||||
state.testStatus === "testing"
|
||||
}
|
||||
>
|
||||
{state.testStatus === "testing" ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("addConnection.httpConnection.connectionTest.button.loading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MousePointerClick className="h-4 w-4" />
|
||||
{t("addConnection.httpConnection.connectionTest.button.label")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{state.testStatus === "success" && (
|
||||
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("addConnection.httpConnection.connectionTest.reachable")}
|
||||
</div>
|
||||
)}
|
||||
{state.testStatus === "failure" && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("addConnection.httpConnection.connectionTest.notReachable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t("addConnection.httpConnection.connectionTest.description")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
validate: () =>
|
||||
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`).success === true &&
|
||||
state.testStatus === "success",
|
||||
build: () => ({
|
||||
type: "http",
|
||||
name: state.name.trim(),
|
||||
url: `${state.protocol}://${state.url.trim()}`,
|
||||
}),
|
||||
},
|
||||
bluetooth: {
|
||||
placeholder: "My Bluetooth Node",
|
||||
children: () => (
|
||||
<>
|
||||
<SupportBadge
|
||||
supported={bluetoothSupported}
|
||||
labelSupported={t("addConnection.bluetoothConnection.supported.title")}
|
||||
labelUnsupported={t("addConnection.bluetoothConnection.notSupported.title")}
|
||||
/>
|
||||
<PickerRow
|
||||
label={t("addConnection.bluetoothConnection.device")}
|
||||
buttonText={t("addConnection.bluetoothConnection.selectDevice")}
|
||||
onPick={handlePickBluetooth}
|
||||
disabled={!bluetoothSupported}
|
||||
display={
|
||||
state.btSelected
|
||||
? state.btSelected.name || state.btSelected.id
|
||||
: t("addConnection.bluetoothConnection.notSelected")
|
||||
}
|
||||
helper={t("addConnection.bluetoothConnection.helperText")}
|
||||
/>
|
||||
<FeatureErrorMessage missingFeatures={unsupported} tabId="bluetooth" />
|
||||
</>
|
||||
),
|
||||
validate: () => state.name.trim().length > 0 && !!state.btSelected,
|
||||
build: () => ({
|
||||
type: "bluetooth",
|
||||
name: state.name.trim(),
|
||||
deviceId: state.btSelected?.id,
|
||||
deviceName: state.btSelected?.name,
|
||||
gattServiceUUID: TransportWebBluetooth.ServiceUuid,
|
||||
}),
|
||||
},
|
||||
serial: {
|
||||
placeholder: t("addConnection.serialConnection.namePlaceholder"),
|
||||
children: () => (
|
||||
<>
|
||||
<SupportBadge
|
||||
supported={serialSupported}
|
||||
labelSupported={t("addConnection.serialConnection.supported.title")}
|
||||
labelUnsupported={t("addConnection.serialConnection.notSupported.title")}
|
||||
/>
|
||||
<PickerRow
|
||||
label={t("addConnection.serialConnection.port")}
|
||||
buttonText={t("addConnection.serialConnection.selectPort")}
|
||||
onPick={handlePickSerial}
|
||||
disabled={!serialSupported}
|
||||
display={
|
||||
state.serialSelected
|
||||
? t("addConnection.serialConnection.deviceName", {
|
||||
vendorId: state.serialSelected.vendorId?.toString(16) ?? "?",
|
||||
productId: state.serialSelected.productId?.toString(16) ?? "?",
|
||||
})
|
||||
: t("addConnection.serialConnection.notSelected")
|
||||
}
|
||||
helper={t("addConnection.serialConnection.helperText")}
|
||||
/>
|
||||
<FeatureErrorMessage missingFeatures={unsupported} tabId="serial" />
|
||||
</>
|
||||
),
|
||||
validate: () =>
|
||||
state.name.trim().length > 0 && (!!state.serialSelected || !serialSupported),
|
||||
build: () => ({
|
||||
type: "serial",
|
||||
name: state.name.trim(),
|
||||
usbVendorId: state.serialSelected?.vendorId,
|
||||
usbProductId: state.serialSelected?.productId,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
[
|
||||
state,
|
||||
bluetoothSupported,
|
||||
serialSupported,
|
||||
isURLHTTPS,
|
||||
handlePickBluetooth,
|
||||
handlePickSerial,
|
||||
handleTestHttp,
|
||||
unsupported,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const currentPane = PANES[state.tab];
|
||||
const canCreate = useMemo(() => currentPane.validate(), [currentPane]);
|
||||
|
||||
const submit =
|
||||
(fn: (p: NewConnection, device?: BluetoothDevice) => Promise<void>) => async () => {
|
||||
if (!canCreate) {
|
||||
return;
|
||||
}
|
||||
const payload = currentPane.build();
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
const btDevice = state.tab === "bluetooth" ? state.btSelected?.device : undefined;
|
||||
await fn(payload, btDevice);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
variant="default"
|
||||
type="custom"
|
||||
title={t("addConnection.title")}
|
||||
description={t("addConnection.description")}
|
||||
cancelText={t("button.cancel")}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Tabs
|
||||
value={state.tab}
|
||||
onValueChange={(v) => dispatch({ type: "SET_TAB", payload: v as TabKey })}
|
||||
>
|
||||
<TabsList className="grid grid-cols-3">
|
||||
{TAB_META.map(({ key, label, Icon }) => (
|
||||
<TabsTrigger key={key} value={key} className="gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TAB_META.map(({ key }) => (
|
||||
<TabsContent key={key} value={key}>
|
||||
{state.tab === key ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Label htmlFor={`name-${state.tab}`}>Name</Label>
|
||||
<Input
|
||||
id={`name-${state.tab}`}
|
||||
value={state.name}
|
||||
onChange={(evt) => dispatch({ type: "SET_NAME", payload: evt.target.value })}
|
||||
placeholder={currentPane.placeholder}
|
||||
/>
|
||||
</div>
|
||||
{PANES[key].children()}
|
||||
<div className="flex justify-end">
|
||||
<div className="inline-flex rounded-md shadow-sm overflow-hidden border">
|
||||
<Button onClick={submit(onSave)} disabled={!canCreate} className="rounded-none">
|
||||
{t("button.saveConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import z from "zod";
|
||||
|
||||
export const urlOrIpv4Schema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => {
|
||||
const input = val.replace(/^https?:\/\//i, "");
|
||||
|
||||
// Split input into host and port (port is optional)
|
||||
const lastColonIndex = input.lastIndexOf(":");
|
||||
let host = input;
|
||||
let port = null;
|
||||
|
||||
if (lastColonIndex !== -1) {
|
||||
const potentialPort = input.substring(lastColonIndex + 1);
|
||||
if (/^\d+$/.test(potentialPort)) {
|
||||
host = input.substring(0, lastColonIndex);
|
||||
port = parseInt(potentialPort, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate port if present
|
||||
if (port !== null) {
|
||||
// Must be 2-5 digits and between 10-65535
|
||||
if (port < 10 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4 pattern
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
// Domain pattern (e.g. example.com, meshtastic.local)
|
||||
const domainRegex = /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}$/;
|
||||
// Local domain (e.g. meshtastic.local)
|
||||
const localDomainRegex = /^(?!-)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.local$/;
|
||||
|
||||
return ipv4Regex.test(host) || domainRegex.test(host) || localDomainRegex.test(host);
|
||||
}, "Must be a valid IPv4 address or domain name with optional port (10-65535)")
|
||||
.transform((val) => {
|
||||
return /^https?:\/\//i.test(val) ? val : `http://${val}`;
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClearAllStoresDialog } from "./ClearAllStoresDialog.tsx";
|
||||
|
||||
const mockClearAllStores = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
clearAllStores: () => mockClearAllStores(),
|
||||
}));
|
||||
|
||||
describe("ClearAllStoresDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
// Capture window.location.href assignment without triggering real navigation
|
||||
const originalLocation = window.location;
|
||||
let assignedHref: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockClearAllStores.mockClear();
|
||||
assignedHref = undefined;
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
get href() {
|
||||
return originalLocation.href;
|
||||
},
|
||||
set href(val: string) {
|
||||
assignedHref = val;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// restore the real location object after each test
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls clearAllStores and navigates to '/' when confirm is clicked", () => {
|
||||
render(<ClearAllStoresDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear all local storage" }));
|
||||
|
||||
expect(mockClearAllStores).toHaveBeenCalledTimes(1);
|
||||
expect(assignedHref).toBe("/"); // forced reload target
|
||||
// We reload instead of toggling the dialog, so ensure we didn't call onOpenChange
|
||||
expect(mockOnOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when cancel is clicked", () => {
|
||||
render(<ClearAllStoresDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockClearAllStores).not.toHaveBeenCalled();
|
||||
expect(assignedHref).toBeUndefined(); // no navigation
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { clearAllStores } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface ClearAllStoresDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ClearAllStoresDialog = ({ open, onOpenChange }: ClearAllStoresDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
const handleClearAllStores = () => {
|
||||
clearAllStores();
|
||||
|
||||
// Reload the app to ensure all state is cleared
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
variant="destructive"
|
||||
title={t("clearAllStores.title")}
|
||||
description={t("clearAllStores.description")}
|
||||
confirmText={t("clearAllStores.confirm")}
|
||||
onConfirm={handleClearAllStores}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const MessageType = Protobuf.Admin.KeyVerificationAdmin_MessageType;
|
||||
|
||||
export interface ClientNotificationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ClientNotificationDialog = ({ open, onOpenChange }: ClientNotificationDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection, getClientNotification, removeClientNotification } = useDevice();
|
||||
const [securityNumber, setSecurityNumber] = useState("");
|
||||
|
||||
const notification = getClientNotification(0);
|
||||
|
||||
const dismiss = () => {
|
||||
removeClientNotification(0);
|
||||
setSecurityNumber("");
|
||||
if (!getClientNotification(0)) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The node correlates the handshake by nonce, so responses pass remoteNodenum 0.
|
||||
const respond = (
|
||||
messageType: Protobuf.Admin.KeyVerificationAdmin_MessageType,
|
||||
nonce: bigint,
|
||||
secNum?: number,
|
||||
) => {
|
||||
connection?.sendKeyVerification(messageType, 0, nonce, secNum).catch((error) => {
|
||||
console.error("Failed to send key verification message:", error);
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
|
||||
const dialogContent = (() => {
|
||||
const variant = notification?.payloadVariant;
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (variant.case) {
|
||||
case "keyVerificationNumberInform": {
|
||||
const value = variant.value;
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.inform.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.inform.description", { name: value.remoteLongname })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="py-4 text-center font-mono text-4xl tracking-widest">
|
||||
{value.securityNumber}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="default" onClick={dismiss}>
|
||||
{t("keyVerification.continue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case "keyVerificationNumberRequest": {
|
||||
const value = variant.value;
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.request.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.request.description", { name: value.remoteLongname })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={securityNumber}
|
||||
onChange={(e) => setSecurityNumber(e.target.value)}
|
||||
placeholder={t("keyVerification.request.placeholder")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismiss}>
|
||||
{t("keyVerification.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={securityNumber === ""}
|
||||
onClick={() =>
|
||||
respond(MessageType.PROVIDE_SECURITY_NUMBER, value.nonce, Number(securityNumber))
|
||||
}
|
||||
>
|
||||
{t("keyVerification.request.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case "keyVerificationFinal": {
|
||||
const value = variant.value;
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("keyVerification.final.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("keyVerification.final.description", { name: value.remoteLongname })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="py-4 text-center font-mono text-4xl tracking-widest">
|
||||
{value.verificationCharacters}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => respond(MessageType.DO_NOT_VERIFY, value.nonce)}
|
||||
>
|
||||
{t("keyVerification.final.reject")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => respond(MessageType.DO_VERIFY, value.nonce)}>
|
||||
{t("keyVerification.final.verify")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("clientNotification.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t([`clientNotification.${notification?.message}`, notification?.message ?? ""])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={dismiss}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
{dialogContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
import { type MessageStore, useMessages } from "@core/stores";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useMessages: vi.fn(() => ({
|
||||
deleteAllMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("DeleteMessagesDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockClearAllMessages.mockClear();
|
||||
|
||||
const mockedUseMessages = vi.mocked(useMessages);
|
||||
mockedUseMessages.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
deleteAllMessages: mockClearAllMessages,
|
||||
}) as unknown as MessageStore,
|
||||
);
|
||||
mockedUseMessages.mockClear();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
const closeButton = screen.queryByTestId("dialog-close-button");
|
||||
if (!closeButton) {
|
||||
throw new Error(
|
||||
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
|
||||
);
|
||||
}
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear Messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the dialog when open is false", () => {
|
||||
render(<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.queryByText("Clear All Messages")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||
render(<DeleteMessagesDialog open 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} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMessages } from "@app/core/stores/index.ts";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface DeleteMessagesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeleteMessagesDialog = ({ open, onOpenChange }: DeleteMessagesDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const messageStore = useMessages();
|
||||
|
||||
const handleConfirm = () => {
|
||||
messageStore.deleteAllMessages();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
title={t("deleteMessages.title")}
|
||||
description={t("deleteMessages.description")}
|
||||
icon={<AlertTriangleIcon className="h-5 w-5 text-warning" />}
|
||||
variant="destructive"
|
||||
confirmText={t("button.clearMessages")}
|
||||
cancelText={t("button.dismiss")}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { FactoryResetConfigDialog } from "@app/components/Dialog/FactoryResetConfigDialog/FactoryResetConfigDialog";
|
||||
import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog";
|
||||
import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx";
|
||||
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.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 { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
|
||||
import { ResetNodeDbDialog } from "@components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
return (
|
||||
<>
|
||||
<QRDialog
|
||||
open={dialog.QR}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("QR", open);
|
||||
}}
|
||||
channels={channels}
|
||||
loraConfig={config.lora}
|
||||
/>
|
||||
<ImportDialog
|
||||
open={dialog.import}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("import", open);
|
||||
}}
|
||||
loraConfig={config.lora}
|
||||
/>
|
||||
<ShutdownDialog
|
||||
open={dialog.shutdown}
|
||||
onOpenChange={() => {
|
||||
setDialogOpen("shutdown", false);
|
||||
}}
|
||||
/>
|
||||
<RebootDialog
|
||||
open={dialog.reboot}
|
||||
onOpenChange={() => {
|
||||
setDialogOpen("reboot", false);
|
||||
}}
|
||||
/>
|
||||
<RemoveNodeDialog
|
||||
open={dialog.nodeRemoval}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("nodeRemoval", open);
|
||||
}}
|
||||
/>
|
||||
<PkiBackupDialog
|
||||
open={dialog.pkiBackup}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("pkiBackup", open);
|
||||
}}
|
||||
/>
|
||||
<NodeDetailsDialog
|
||||
open={dialog.nodeDetails}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("nodeDetails", open);
|
||||
}}
|
||||
/>
|
||||
<UnsafeRolesDialog
|
||||
open={dialog.unsafeRoles}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("unsafeRoles", open);
|
||||
}}
|
||||
/>
|
||||
<RefreshKeysDialog
|
||||
open={dialog.refreshKeys}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
<DeleteMessagesDialog
|
||||
open={dialog.deleteMessages}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("deleteMessages", open);
|
||||
}}
|
||||
/>
|
||||
<ClientNotificationDialog
|
||||
open={dialog.clientNotification}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("clientNotification", open);
|
||||
}}
|
||||
/>
|
||||
<ResetNodeDbDialog
|
||||
open={dialog.resetNodeDb}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("resetNodeDb", open);
|
||||
}}
|
||||
/>
|
||||
<ClearAllStoresDialog
|
||||
open={dialog.clearAllStores}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("clearAllStores", open);
|
||||
}}
|
||||
/>
|
||||
<FactoryResetDeviceDialog
|
||||
open={dialog.factoryResetDevice}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("factoryResetDevice", open);
|
||||
}}
|
||||
/>
|
||||
<FactoryResetConfigDialog
|
||||
open={dialog.factoryResetConfig}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("factoryResetConfig", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type DialogVariant = "default" | "destructive";
|
||||
export type DialogType = "confirm" | "alert" | "info" | "custom";
|
||||
|
||||
export interface DialogWrapperProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: DialogType;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
|
||||
showFooter?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
dismissText?: string;
|
||||
variant?: DialogVariant;
|
||||
confirmIcon?: ReactNode;
|
||||
customFooter?: ReactNode;
|
||||
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const DialogWrapper = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
type = "custom",
|
||||
icon,
|
||||
children,
|
||||
showFooter = true,
|
||||
confirmText,
|
||||
cancelText,
|
||||
dismissText,
|
||||
variant = "default",
|
||||
confirmIcon,
|
||||
customFooter,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
}: DialogWrapperProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (onConfirm) {
|
||||
await onConfirm();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!showFooter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customFooter) {
|
||||
return <DialogFooter className="mt-4">{customFooter}</DialogFooter>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "confirm":
|
||||
return (
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancel} name="cancel">
|
||||
{cancelText || t("button.cancel")}
|
||||
</Button>
|
||||
<Button variant={variant} onClick={handleConfirm} name="confirm">
|
||||
{confirmIcon && <span className="mr-2">{confirmIcon}</span>}
|
||||
{confirmText || t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
case "alert":
|
||||
case "info":
|
||||
return (
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleDismiss} name="dismiss">
|
||||
{dismissText || t("button.dismiss")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{icon && icon}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{type !== "custom" && <Separator />}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
{renderFooter()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FactoryResetConfigDialog } from "./FactoryResetConfigDialog.tsx";
|
||||
|
||||
const mockFactoryReset = vi.fn();
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
connection: {
|
||||
factoryResetConfig: mockFactoryReset,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||
toast: (...args: unknown[]) => mockToast(...args),
|
||||
}));
|
||||
|
||||
describe("FactoryResetConfigDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockFactoryReset.mockReset();
|
||||
mockToast.mockClear();
|
||||
mockFactoryReset.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("calls factoryResetConfig and then closes the dialog on confirm", async () => {
|
||||
render(<FactoryResetConfigDialog open onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Factory Reset Config" }));
|
||||
|
||||
expect(mockFactoryReset).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// success path: no toast
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenChange(false) and does not call factoryResetConfig when cancel is clicked", async () => {
|
||||
render(<FactoryResetConfigDialog open onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
expect(mockFactoryReset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface FactoryResetConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FactoryResetConfigDialog = ({ open, onOpenChange }: FactoryResetConfigDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
const handleFactoryResetConfig = () => {
|
||||
connection?.factoryResetConfig().catch((error) => {
|
||||
toast({
|
||||
title: t("factoryResetConfig.failedTitle"),
|
||||
});
|
||||
console.error("Failed to factory reset config:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
variant="destructive"
|
||||
title={t("factoryResetConfig.title")}
|
||||
description={t("factoryResetConfig.description")}
|
||||
confirmText={t("factoryResetConfig.confirm")}
|
||||
onConfirm={handleFactoryResetConfig}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
|
||||
|
||||
const mockFactoryResetDevice = vi.fn();
|
||||
const mockRemoveDevice = vi.fn();
|
||||
const mockRemoveMessageStore = vi.fn();
|
||||
const mockRemoveNodeDB = vi.fn();
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => {
|
||||
// Make each store a callable fn (like a Zustand hook), and attach .getState()
|
||||
const useDeviceStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeDevice: mockRemoveDevice }),
|
||||
});
|
||||
const useMessageStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
|
||||
});
|
||||
const useNodeDBStore = Object.assign(vi.fn(), {
|
||||
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
|
||||
});
|
||||
|
||||
return {
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
id: 42,
|
||||
connection: { factoryResetDevice: mockFactoryResetDevice },
|
||||
}),
|
||||
useDeviceStore,
|
||||
useMessageStore,
|
||||
useNodeDBStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||
toast: (...args: unknown[]) => mockToast(...args),
|
||||
}));
|
||||
|
||||
describe("FactoryResetDeviceDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockFactoryResetDevice.mockReset();
|
||||
mockRemoveDevice.mockClear();
|
||||
mockRemoveMessageStore.mockClear();
|
||||
mockRemoveNodeDB.mockClear();
|
||||
mockToast.mockClear();
|
||||
});
|
||||
|
||||
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
|
||||
// Control the promise returned by factoryResetDevice
|
||||
let resolveReset: (() => void) | undefined;
|
||||
mockFactoryResetDevice.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveReset = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<FactoryResetDeviceDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Factory Reset Device" }));
|
||||
|
||||
// Called immediately
|
||||
expect(mockFactoryResetDevice).toHaveBeenCalledTimes(1);
|
||||
|
||||
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// Resolve the reset
|
||||
resolveReset?.();
|
||||
|
||||
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
|
||||
render(<FactoryResetDeviceDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
expect(mockFactoryResetDevice).not.toHaveBeenCalled();
|
||||
expect(mockRemoveDevice).not.toHaveBeenCalled();
|
||||
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
|
||||
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface FactoryResetDeviceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FactoryResetDeviceDialog = ({ open, onOpenChange }: FactoryResetDeviceDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection, id } = useDevice();
|
||||
|
||||
const handleFactoryResetDevice = () => {
|
||||
connection?.factoryResetDevice().catch((error) => {
|
||||
toast({
|
||||
title: t("factoryResetDevice.failedTitle"),
|
||||
});
|
||||
console.error("Failed to factory reset device:", error);
|
||||
});
|
||||
|
||||
// The device will be wiped and disconnected without resolving the promise
|
||||
// so we proceed to clear all data associated with the device immediately
|
||||
useDeviceStore.getState().removeDevice(id);
|
||||
useMessageStore.getState().removeMessageStore(id);
|
||||
useNodeDBStore.getState().removeNodeDB(id);
|
||||
|
||||
// Reload the app to ensure all ephemeral state is cleared
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
variant="destructive"
|
||||
title={t("factoryResetDevice.title")}
|
||||
description={t("factoryResetDevice.description")}
|
||||
confirmText={t("factoryResetDevice.confirm")}
|
||||
onConfirm={handleFactoryResetDevice}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
loraConfig?: Protobuf.Config.Config_LoRaConfig;
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const { setChange, channels, config } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||
const [updateConfig, setUpdateConfig] = useState<boolean>(true);
|
||||
const [importIndex, setImportIndex] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// the channel information is contained in the URL's fragment, which will be present after a
|
||||
// non-URL encoded `#`.
|
||||
try {
|
||||
const channelsUrl = new URL(importDialogInput);
|
||||
if (
|
||||
(channelsUrl.hostname !== "meshtastic.org" && channelsUrl.pathname !== "/e/") ||
|
||||
!channelsUrl.hash
|
||||
) {
|
||||
throw t("import.error.invalidUrl");
|
||||
}
|
||||
|
||||
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(encodedChannelConfig.length + ((4 - (encodedChannelConfig.length % 4)) % 4), "=")
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const newChannelSet = fromBinary(
|
||||
Protobuf.AppOnly.ChannelSetSchema,
|
||||
toByteArray(paddedString),
|
||||
);
|
||||
|
||||
const newImportChannelArray = newChannelSet.settings.map((_, idx) => idx);
|
||||
|
||||
setChannelSet(newChannelSet);
|
||||
setImportIndex(newImportChannelArray);
|
||||
setUpdateConfig(newChannelSet?.loraConfig !== undefined);
|
||||
setValidUrl(true);
|
||||
} catch {
|
||||
setValidUrl(false);
|
||||
setChannelSet(undefined);
|
||||
}
|
||||
}, [importDialogInput, t]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.forEach((ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
if (importIndex[index] === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = create(Protobuf.Channel.ChannelSchema, {
|
||||
index: importIndex[index],
|
||||
role:
|
||||
importIndex[index] === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
});
|
||||
|
||||
if (!deepCompareConfig(channels.get(importIndex[index] ?? 0), payload, true)) {
|
||||
setChange(
|
||||
{ type: "channel", index: importIndex[index] ?? 0 },
|
||||
payload,
|
||||
channels.get(importIndex[index] ?? 0),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (channelSet?.loraConfig && updateConfig) {
|
||||
const payload = {
|
||||
...config.lora,
|
||||
...channelSet.loraConfig,
|
||||
};
|
||||
|
||||
if (!deepCompareConfig(config.lora, payload, true)) {
|
||||
setChange({ type: "config", variant: "lora" }, payload, config.lora);
|
||||
}
|
||||
}
|
||||
// Reset state after import
|
||||
setImportDialogInput("");
|
||||
setChannelSet(undefined);
|
||||
setValidUrl(false);
|
||||
setImportIndex([]);
|
||||
setUpdateConfig(true);
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const onSelectChange = (value: string, index: number) => {
|
||||
const newImportIndex = [...importIndex];
|
||||
newImportIndex[index] = Number.parseInt(value, 10);
|
||||
setImportIndex(newImportIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={"import.description"} components={{ italic: <i />, br: <br /> }} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>{t("import.channelSetUrl")}</Label>
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
variant={importDialogInput === "" ? "default" : validUrl ? "dirty" : "invalid"}
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{validUrl && (
|
||||
<div className="flex flex-col gap-6 mt-2">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className=" flex items-center">
|
||||
<Switch
|
||||
className="ml-3 mr-4"
|
||||
checked={updateConfig}
|
||||
onCheckedChange={(next) => setUpdateConfig(next)}
|
||||
/>
|
||||
<Label className="">
|
||||
{t("import.useLoraConfig")}
|
||||
<span className="block pt-2 font-normal text-s">
|
||||
{t("import.presetDescription")}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center font-semibold text-sm">
|
||||
<span className="flex-1">{t("import.channelName")}</span>
|
||||
<span className="flex-1">{t("import.channelSlot")}</span>
|
||||
</div>
|
||||
{channelSet?.settings.map((channel, index) => (
|
||||
<div className="flex items-center" key={`channel_${channel.id}_${index}`}>
|
||||
<Label className="flex-1">
|
||||
{channel.name.length
|
||||
? channel.name
|
||||
: `${t("import.channelPrefix")}${channel.id}`}
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => onSelectChange(value, index)}
|
||||
value={importIndex[index]?.toString()}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((i) => (
|
||||
<SelectItem
|
||||
key={`index_${i}`}
|
||||
disabled={importIndex.includes(i) && index !== i}
|
||||
value={i.toString()}
|
||||
>
|
||||
{i === 0 ? t("import.primary") : `${t("import.channelPrefix")}${i}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="-1">{t("import.doNotImport")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={apply} disabled={!validUrl} name="apply">
|
||||
{t("button.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
|
||||
export interface LocationResponseDialogProps {
|
||||
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const LocationResponseDialog = ({
|
||||
location,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LocationResponseDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useNodeDB();
|
||||
|
||||
const from = getNode(location?.from ?? 0);
|
||||
const longName =
|
||||
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const shortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName"));
|
||||
|
||||
const position = location?.data;
|
||||
|
||||
const hasCoordinates =
|
||||
position &&
|
||||
typeof position.latitudeI === "number" &&
|
||||
typeof position.longitudeI === "number" &&
|
||||
typeof position.altitude === "number";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("locationResponse.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
identifier: `${longName} (${shortName})`,
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export interface ManagedModeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const ManagedModeDialog = ({ open, onOpenChange, onSubmit }: ManagedModeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("managedMode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="managedMode.description"
|
||||
components={{
|
||||
bold: <p className="font-bold inline" />,
|
||||
}}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<p className="dark:text-white pt-1">{t("managedMode.confirmUnderstanding")}</p>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="regenerate"
|
||||
disabled={!confirmState}
|
||||
onClick={() => {
|
||||
setConfirmState(false);
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
{t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Tabs, TabsContent, 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";
|
||||
|
||||
export interface TabElementProps {
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
export interface TabManifest {
|
||||
id: "HTTP" | "BLE" | "Serial";
|
||||
label: string;
|
||||
element: React.FC<TabElementProps>;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface NewDeviceProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
tabId: "HTTP" | "BLE" | "Serial";
|
||||
}
|
||||
|
||||
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",
|
||||
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
|
||||
},
|
||||
"Web Serial": {
|
||||
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",
|
||||
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
|
||||
},
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const needsFeature =
|
||||
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
|
||||
? "Web Bluetooth"
|
||||
: 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">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-white">
|
||||
{needsFeature && (
|
||||
<Trans
|
||||
i18nKey={errors[needsFeature].i18nKey}
|
||||
components={[
|
||||
<Link
|
||||
key="0"
|
||||
href={errors[needsFeature].href}
|
||||
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{needsFeature && needsSecureContext && " "}
|
||||
{needsSecureContext && (
|
||||
<Trans
|
||||
i18nKey={
|
||||
browserFeatures.length > 0
|
||||
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||
: "newDeviceDialog.validation.requiresSecureContext"
|
||||
}
|
||||
components={{
|
||||
"0": (
|
||||
<Link
|
||||
href={errors["Secure Context"].href}
|
||||
className="underline hover:text-slate-200"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
id: "HTTP",
|
||||
label: t("newDeviceDialog.tabHttp"),
|
||||
element: HTTP,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
id: "BLE",
|
||||
label: t("newDeviceDialog.tabBluetooth"),
|
||||
element: BLE,
|
||||
isDisabled: unsupported.includes("Web Bluetooth") || unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
id: "Serial",
|
||||
label: t("newDeviceDialog.tabSerial"),
|
||||
element: Serial,
|
||||
isDisabled: unsupported.includes("Web Serial") || unsupported.includes("Secure Context"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="HTTP">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{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)} />
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/UI/Accordion.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Separator } from "@components/UI/Separator.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, useDevice, useNodeDB } from "@core/stores";
|
||||
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 { fromByteArray } from "base64-js";
|
||||
import {
|
||||
BellIcon,
|
||||
BellOffIcon,
|
||||
MapPinnedIcon,
|
||||
MessageSquareIcon,
|
||||
ShieldCheckIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { setDialogOpen, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const navigate = useNavigate();
|
||||
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
|
||||
const { updateFavorite } = useFavoriteNode();
|
||||
const { updateIgnored } = useIgnoreNode();
|
||||
|
||||
const node = getNode(nodeNumDetails);
|
||||
|
||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(node?.isFavorite ?? false);
|
||||
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(node?.isIgnored ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setIsFavoriteState(node?.isFavorite);
|
||||
setIsIgnoredState(node?.isIgnored);
|
||||
}, [node]);
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
navigate({ to: `/messages/direct/${node.num}` });
|
||||
setDialogOpen("nodeDetails", false);
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("toast.requestingPosition.title", { ns: "ui" }),
|
||||
});
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.positionRequestSent.title", { ns: "ui" }),
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
|
||||
});
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.tracerouteSent.title", { ns: "ui" }),
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleInitiateKeyVerification() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
connection
|
||||
?.sendKeyVerification(
|
||||
Protobuf.Admin.KeyVerificationAdmin_MessageType.INITIATE_VERIFICATION,
|
||||
node.num,
|
||||
0n,
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("Failed to initiate key verification:", error);
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleNodeRemove() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNodeNumToBeRemoved(node?.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
|
||||
setIsFavoriteState(!isFavoriteState);
|
||||
}
|
||||
|
||||
function handleToggleIgnored() {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
|
||||
setIsIgnoredState(!isIgnoredState);
|
||||
}
|
||||
|
||||
const deviceMetricsMap = [
|
||||
{
|
||||
key: "airUtilTx",
|
||||
label: t("nodeDetails.airTxUtilization"),
|
||||
value: node.deviceMetrics?.airUtilTx,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "channelUtilization",
|
||||
label: t("nodeDetails.channelUtilization"),
|
||||
value: node.deviceMetrics?.channelUtilization,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "batteryLevel",
|
||||
label: t("nodeDetails.batteryLevel"),
|
||||
value: node.deviceMetrics?.batteryLevel,
|
||||
format: (val: number) => (val === 101 ? t("batteryStatus.pluggedIn") : `${val.toFixed(2)}%`),
|
||||
},
|
||||
{
|
||||
key: "voltage",
|
||||
label: t("nodeDetails.voltage"),
|
||||
value:
|
||||
typeof node.deviceMetrics?.voltage === "number"
|
||||
? Math.abs(node.deviceMetrics?.voltage)
|
||||
: undefined,
|
||||
format: (val: number) => `${val.toFixed(2)}V`,
|
||||
},
|
||||
];
|
||||
|
||||
const sectionClassName =
|
||||
"text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-4 rounded-lg mt-3";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("nodeDetails.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
|
||||
node.user?.shortName ?? t("unknown.shortName")
|
||||
})`,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full ">
|
||||
<div className="flex flex-row flex-wrap space-y-1">
|
||||
<Button className="mr-1" name="message" onClick={handleDirectMessage}>
|
||||
<MessageSquareIcon className="mr-2" />
|
||||
{t("nodeDetails.message")}
|
||||
</Button>
|
||||
<Button className="mr-1" name="traceRoute" onClick={handleTraceroute}>
|
||||
<WaypointsIcon className="mr-2" />
|
||||
{t("nodeDetails.traceRoute")}
|
||||
</Button>
|
||||
<Button className="mr-1" onClick={handleToggleFavorite}>
|
||||
<StarIcon
|
||||
className={cn(isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "")}
|
||||
/>
|
||||
</Button>
|
||||
{node.user?.publicKey && node.user.publicKey.length > 0 && (
|
||||
<Button className="mr-1" name="verifyKey" onClick={handleInitiateKeyVerification}>
|
||||
<ShieldCheckIcon className="mr-2" />
|
||||
{t("nodeDetails.verifyKey")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-1 justify-start" />
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex justify-end mr-1 text-white",
|
||||
isIgnoredState
|
||||
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
|
||||
: "",
|
||||
)}
|
||||
onClick={handleToggleIgnored}
|
||||
>
|
||||
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{isIgnoredState ? t("nodeDetails.unignoreNode") : t("nodeDetails.ignoreNode")}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex justify-end"
|
||||
onClick={handleNodeRemove}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{t("nodeDetails.removeNode")}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-5 mb-2" />
|
||||
|
||||
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.details")}</p>
|
||||
<table className="table-fixed w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.nodeNumber")}</td>
|
||||
<td>{node.num}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.nodeHexPrefix")}</td>
|
||||
<td>!{numberToHexUnpadded(node.num)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.role")}</td>
|
||||
<td>
|
||||
{Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0]?.replace(
|
||||
/_/g,
|
||||
" ",
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.lastHeard")}</td>
|
||||
<td>
|
||||
{node.lastHeard === 0 ? (
|
||||
t("nodesTable.lastHeardStatus.never", {
|
||||
ns: "nodes",
|
||||
})
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.hardware")}</td>
|
||||
<td>
|
||||
{(
|
||||
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||
t("unknown.shortName")
|
||||
).replace(/_/g, " ")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("nodeDetails.messageable")}</td>
|
||||
<td>{node.user?.isUnmessagable ? t("no") : t("yes")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DeviceImage
|
||||
className="w-40 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNKNOWN"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={sectionClassName}>
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.security")}</p>
|
||||
<table className="table-auto w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="pr-2">{t("nodeDetails.publicKey")}</td>
|
||||
<td>
|
||||
<pre className="text-xs pt-0.5">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? fromByteArray(node.user.publicKey)
|
||||
: t("unknown.longName")}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
{node.isKeyManuallyVerified
|
||||
? t("nodeDetails.KeyManuallyVerifiedTrue")
|
||||
: t("nodeDetails.KeyManuallyVerifiedFalse")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={sectionClassName}>
|
||||
<p className="text-lg font-semibold">{t("nodeDetails.position")}</p>
|
||||
|
||||
{node.position ? (
|
||||
<table className="table-auto w-full">
|
||||
<tbody>
|
||||
{node.position.latitudeI && node.position.longitudeI && (
|
||||
<tr>
|
||||
<td>{t("locationResponse.coordinates")}</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{node.position.altitude && (
|
||||
<tr>
|
||||
<td>{t("locationResponse.altitude")}</td>
|
||||
<td>
|
||||
{node.position.altitude}
|
||||
{t("unit.meter.suffix")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p>{t("unknown.longName")}</p>
|
||||
)}
|
||||
<Button onClick={handleRequestPosition} name="requestPosition" className="mt-2">
|
||||
<MapPinnedIcon className="mr-2" />
|
||||
{t("nodeDetails.requestPosition")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{node.deviceMetrics && (
|
||||
<div className={sectionClassName}>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{t("nodeDetails.deviceMetrics")}
|
||||
</p>
|
||||
<table className="table-fixed w-full">
|
||||
<tbody>
|
||||
{deviceMetricsMap
|
||||
.filter((metric) => metric.value !== undefined)
|
||||
.map((metric) => (
|
||||
<tr key={metric.key}>
|
||||
<td>{metric.label}: </td>
|
||||
<td>{metric.format(metric?.value ?? 0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{node.deviceMetrics.uptimeSeconds && (
|
||||
<tr>
|
||||
<td>{t("nodeDetails.uptime")}</td>
|
||||
<td>
|
||||
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 rounded-lg mt-3">
|
||||
<Accordion className="AccordionRoot" type="single" collapsible>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
{t("nodeDetails.allRawMetrics")}
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">{JSON.stringify(node, null, 2)}</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface PkiBackupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { config, setDialogOpen } = useDevice();
|
||||
const { getMyNode } = useNodeDB();
|
||||
const privateKey = config.security?.privateKey;
|
||||
const publicKey = config.security?.publicKey;
|
||||
|
||||
const decodeKeyData = React.useCallback((key: Uint8Array<ArrayBufferLike>) => {
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
return fromByteArray(key ?? new Uint8Array(0));
|
||||
}, []);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
setDialogOpen("pkiBackup", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const renderPrintWindow = React.useCallback(() => {
|
||||
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>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
h1 { font-size: 18px; }
|
||||
p { font-size: 14px; word-break: break-all; }
|
||||
</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>
|
||||
<h3>${t("pkiBackup.secureBackup")}</h3>
|
||||
<h3>${t("pkiBackup.publicKey")}</h3>
|
||||
<p>${decodeKeyData(publicKey)}</p>
|
||||
<h3>${t("pkiBackup.privateKey")}</h3>
|
||||
<p>${decodeKeyData(privateKey)}</p>
|
||||
<p>${t("pkiBackup.footer")}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedPrivateKey = decodeKeyData(privateKey);
|
||||
const decodedPublicKey = decodeKeyData(publicKey);
|
||||
|
||||
const formattedContent = [
|
||||
`${t("pkiBackup.header", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
})}\n\n`,
|
||||
`${t("pkiBackup.privateKey")}\n`,
|
||||
decodedPrivateKey,
|
||||
`\n\n${t("pkiBackup.publicKey")}\n`,
|
||||
decodedPublicKey,
|
||||
`\n\n${t("pkiBackup.footer")}`,
|
||||
].join("");
|
||||
|
||||
const blob = new Blob([formattedContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = t("pkiBackup.fileName", {
|
||||
interpolation: { escapeValue: false },
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
});
|
||||
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
|
||||
<DialogDescription>{t("pkiBackup.secureBackup")}</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">{t("pkiBackup.loseKeysWarning")}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
name="download"
|
||||
onClick={() => createDownloadKeyFile()}
|
||||
className=""
|
||||
>
|
||||
<DownloadIcon size={20} className="mr-2" />
|
||||
{t("button.download")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => renderPrintWindow()}>
|
||||
<PrinterIcon size={20} className="mr-2" />
|
||||
{t("button.print")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface PkiRegenerateDialogProps {
|
||||
text: {
|
||||
title: string;
|
||||
description: string;
|
||||
button: string;
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const PkiRegenerateDialog = ({
|
||||
text = {
|
||||
title: "",
|
||||
description: "",
|
||||
button: "",
|
||||
},
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PkiRegenerateDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const dialogText = {
|
||||
title: text.title || t("pkiRegenerate.title"),
|
||||
description: text.description || t("pkiRegenerate.description"),
|
||||
button: text.button || t("button.regenerate"),
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogText.title}</DialogTitle>
|
||||
<DialogDescription>{dialogText.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" name="regenerate" onClick={() => onSubmit()}>
|
||||
{dialogText.button}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
||||
|
||||
export interface QRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
loraConfig?: Protobuf.Config.Config_LoRaConfig;
|
||||
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
|
||||
}
|
||||
|
||||
export const QRDialog = ({ open, onOpenChange, loraConfig, channels }: QRDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||
|
||||
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
|
||||
|
||||
useEffect(() => {
|
||||
const channelsToEncode = allChannels
|
||||
.filter((ch) => selectedChannels.includes(ch.index))
|
||||
.map((channel) => channel.settings)
|
||||
.filter((ch): ch is Protobuf.Channel.ChannelSettings => !!ch);
|
||||
const encoded = create(
|
||||
Protobuf.AppOnly.ChannelSetSchema,
|
||||
create(Protobuf.AppOnly.ChannelSetSchema, {
|
||||
loraConfig,
|
||||
settings: channelsToEncode,
|
||||
}),
|
||||
);
|
||||
const base64 = fromByteArray(toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
setQrCodeUrl(`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`);
|
||||
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("qr.title")}</DialogTitle>
|
||||
<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">
|
||||
<div className="flex w-40 flex-col gap-2">
|
||||
{allChannels.map((channel) => (
|
||||
<div className="flex justify-between" key={channel.index}>
|
||||
<Label>
|
||||
{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,
|
||||
})}`}
|
||||
</Label>
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
checked={selectedChannels.includes(channel.index)}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(selectedChannels.filter((c) => c !== channel.index));
|
||||
} else {
|
||||
setSelectedChannels([...selectedChannels, channel.index]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<QRCode value={qrCodeUrl} size={200} qrStyle="dots" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||
qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
name="addChannels"
|
||||
onClick={() => setQrCodeAdd(true)}
|
||||
>
|
||||
{t("qr.addChannels")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||
!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
name="replaceChannels"
|
||||
onClick={() => setQrCodeAdd(false)}
|
||||
>
|
||||
{t("qr.replaceChannels")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Label>{t("qr.sharableUrl")}</Label>
|
||||
<Input value={qrCodeUrl} disabled />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ButtonHTMLAttributes, ClassAttributes, InputHTMLAttributes, ReactNode } from "react";
|
||||
import type { JSX } from "react/jsx-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RebootDialog } from "./RebootDialog.tsx";
|
||||
|
||||
const rebootMock = vi.fn();
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection:
|
||||
| {
|
||||
rebootOta: (delay: number) => void;
|
||||
reboot: (delay: number) => void;
|
||||
}
|
||||
| undefined = {
|
||||
reboot: rebootMock,
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Button: (
|
||||
props: JSX.IntrinsicAttributes &
|
||||
ClassAttributes<HTMLButtonElement> &
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Input: (
|
||||
props: JSX.IntrinsicAttributes &
|
||||
ClassAttributes<HTMLInputElement> &
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("RebootDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders dialog with default input value", () => {
|
||||
render(<RebootDialog open onOpenChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByRole("heading", { name: /reboot device/i, level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls correct reboot function based on OTA checkbox state", () => {
|
||||
render(<RebootDialog open onOpenChange={() => {}} />);
|
||||
|
||||
// Schedule non-OTA reboot
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
});
|
||||
expect(rebootMock).toHaveBeenCalledWith(5);
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
|
||||
rebootMock.mockClear();
|
||||
rebootOtaMock.mockClear();
|
||||
|
||||
// Cancel scheduled
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("cancelRebootBtn"));
|
||||
});
|
||||
expect(rebootMock).toHaveBeenCalledWith(-1);
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
|
||||
rebootMock.mockClear();
|
||||
rebootOtaMock.mockClear();
|
||||
|
||||
// Schedule OTA reboot
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText(/reboot into ota mode/i));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
});
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(5);
|
||||
expect(rebootMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules a reboot with delay and calls rebootOta", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
});
|
||||
|
||||
expect(rebootMock).toHaveBeenCalledWith(3);
|
||||
|
||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("triggers an instant reboot", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
|
||||
});
|
||||
|
||||
expect(rebootMock).toHaveBeenCalledWith(0);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not call reboot if connection is undefined", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(rebootMock).not.toHaveBeenCalled();
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
|
||||
mockConnection = { reboot: rebootMock, rebootOta: rebootOtaMock };
|
||||
});
|
||||
|
||||
it("cancels a scheduled reboot and calls rebootOta with -1", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: "4" },
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
});
|
||||
expect(rebootMock).toHaveBeenCalledWith(4);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
});
|
||||
expect(rebootMock).toHaveBeenCalledWith(-1);
|
||||
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { ClockIcon, OctagonXIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RebootDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [isOTA, setIsOTA] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
|
||||
|
||||
const handleReboot = (delay: number) => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOTA) {
|
||||
connection.rebootOta(delay);
|
||||
} else {
|
||||
connection.reboot(delay);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
|
||||
const parsed = Number(val);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
setTime(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebootWithTimeout = async () => {
|
||||
setIsScheduled(true);
|
||||
|
||||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||
|
||||
handleReboot(delay);
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setIsScheduled(false);
|
||||
onOpenChange(false);
|
||||
setInputValue(DEFAULT_REBOOT_DELAY.toString());
|
||||
}, delay * 1000);
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
clearTimeout(timeoutId);
|
||||
setIsScheduled(false);
|
||||
handleReboot(-1);
|
||||
};
|
||||
|
||||
const handleInstantReboot = async () => {
|
||||
handleReboot(0);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("reboot.title")}</DialogTitle>
|
||||
<DialogDescription>{t("reboot.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Separator />
|
||||
{!isScheduled ? (
|
||||
<>
|
||||
<Checkbox checked={isOTA} onChange={(checked) => setIsOTA(checked)} className="px-2">
|
||||
{t("reboot.ota")}
|
||||
</Checkbox>
|
||||
<div className="flex gap-2 px-2 items-center relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
value={inputValue}
|
||||
onChange={handleSetTime}
|
||||
placeholder={t("reboot.enterDelay")}
|
||||
suffix={t("unit.second.plural")}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleRebootWithTimeout()}
|
||||
data-testid="scheduleRebootBtn"
|
||||
className="w-9/12"
|
||||
>
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{t("reboot.schedule")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="rebootNow"
|
||||
onClick={() => handleInstantReboot()}
|
||||
className=" w-full"
|
||||
>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
{t("reboot.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2">
|
||||
<div className="pb-6 pt-2 text-center">
|
||||
<Label className=" text-gray-700 dark:text-gray-300 ">{t("reboot.scheduled")}</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="cancelReboot"
|
||||
onClick={() => handleCancel()}
|
||||
className=" w-full"
|
||||
data-testid="cancelRebootBtn"
|
||||
>
|
||||
<OctagonXIcon className="mr-2" size={16} />
|
||||
{t("reboot.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
vi.mock("@core/stores", async () => {
|
||||
const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores");
|
||||
return {
|
||||
...actual,
|
||||
useMessageStore: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const getInitialState = () =>
|
||||
useDeviceStore.getInitialState?.() ?? {
|
||||
devices: new Map(),
|
||||
remoteDevices: new Map(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("does not render dialog if no error exists for active chat", () => {
|
||||
const deviceId = 1;
|
||||
const activeChatNum = 54321;
|
||||
|
||||
useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
handleCloseDialog: vi.fn(),
|
||||
handleNodeRemove: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<CurrentDeviceContext.Provider value={{ deviceId }}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</CurrentDeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useMessages, useNodeDB } from "@core/stores";
|
||||
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) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { activeChat } = useMessages();
|
||||
const { nodeErrors, getNode } = useNodeDB();
|
||||
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
|
||||
const nodeErrorNum = nodeErrors.get(activeChat);
|
||||
|
||||
if (!nodeErrorNum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeWithError = getNode(nodeErrorNum.node);
|
||||
|
||||
const text = {
|
||||
title: t("refreshKeys.title", {
|
||||
interpolation: { escapeValue: false },
|
||||
identifier: nodeWithError?.user?.longName ?? "",
|
||||
}),
|
||||
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
|
||||
nodeWithError?.user?.longName ?? ""
|
||||
} (${nodeWithError?.user?.shortName ?? ""})${t(
|
||||
"refreshKeys.description.keyMismatchReasonSuffix",
|
||||
)}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2" aria-describedby={undefined}>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{text.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{text.description}
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">{t("refreshKeys.label.acceptNewKeys")}</p>
|
||||
<p>{t("refreshKeys.description.acceptNewKeys")}</p>
|
||||
</div>
|
||||
<Button variant="default" name="requestNewKeys" onClick={handleNodeRemove}>
|
||||
{t("button.requestNewKeys")}
|
||||
</Button>
|
||||
<Button variant="outline" name="dismiss" onClick={handleCloseDialog}>
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { setDialogOpen } = useDevice();
|
||||
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
|
||||
const { activeChat } = useMessages();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("refreshKeys", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
handleCloseDialog();
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||
|
||||
export interface RemoveNodeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const { getNode, removeNode } = useNodeDB();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
|
||||
const handleConfirm = () => {
|
||||
connection?.removeNodeByNum(nodeNumToBeRemoved);
|
||||
removeNode(nodeNumToBeRemoved);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
title={t("removeNode.title")}
|
||||
description={t("removeNode.description")}
|
||||
variant="destructive"
|
||||
confirmText={t("button.remove")}
|
||||
onConfirm={handleConfirm}
|
||||
>
|
||||
<div className="gap-4">
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
// ResetNodeDbDialog.test.tsx
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx";
|
||||
|
||||
const mockResetNodes = vi.fn();
|
||||
const mockDeleteAllMessages = vi.fn();
|
||||
const mockRemoveAllNodeErrors = vi.fn();
|
||||
const mockRemoveAllNodes = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
connection: {
|
||||
resetNodes: mockResetNodes,
|
||||
},
|
||||
}),
|
||||
useMessages: () => ({
|
||||
deleteAllMessages: mockDeleteAllMessages,
|
||||
}),
|
||||
useNodeDB: () => ({
|
||||
removeAllNodeErrors: mockRemoveAllNodeErrors,
|
||||
removeAllNodes: mockRemoveAllNodes,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ResetNodeDbDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockResetNodes.mockClear();
|
||||
mockDeleteAllMessages.mockClear();
|
||||
mockRemoveAllNodeErrors.mockClear();
|
||||
mockRemoveAllNodes.mockClear();
|
||||
});
|
||||
|
||||
it("calls resetNodes, closes dialog, and after resolve clears messages and node DB (with true flag)", async () => {
|
||||
// Control the promise returned by resetNodes
|
||||
let resolveReset: (() => void) | undefined;
|
||||
mockResetNodes.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveReset = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Node Database" }));
|
||||
|
||||
// Called immediately
|
||||
expect(mockResetNodes).toHaveBeenCalledTimes(1);
|
||||
|
||||
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
// Nothing else should have happened yet (the promise hasn't resolved)
|
||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
||||
|
||||
// Resolve the reset
|
||||
resolveReset?.();
|
||||
|
||||
// Now the .then() chain should fire
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveAllNodes).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onOpenChange(false) and does not call resetNodes when cancel is clicked", async () => {
|
||||
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
expect(mockResetNodes).not.toHaveBeenCalled();
|
||||
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
|
||||
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "../DialogWrapper.tsx";
|
||||
|
||||
export interface ResetNodeDbDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
|
||||
const { deleteAllMessages } = useMessages();
|
||||
|
||||
const handleResetNodeDb = () => {
|
||||
connection
|
||||
?.resetNodes()
|
||||
.then(() => {
|
||||
deleteAllMessages();
|
||||
removeAllNodeErrors();
|
||||
removeAllNodes(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: t("resetNodeDb.failedTitle"),
|
||||
});
|
||||
console.error("Failed to reset Node DB:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="confirm"
|
||||
variant="destructive"
|
||||
title={t("resetNodeDb.title")}
|
||||
description={t("resetNodeDb.description")}
|
||||
confirmText={t("resetNodeDb.confirm")}
|
||||
onConfirm={handleResetNodeDb}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogWrapper } from "./DialogWrapper.tsx";
|
||||
|
||||
export interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(5);
|
||||
|
||||
const handleScheduledShutdown = () => {
|
||||
connection?.shutdown(time * 60).then(() => onOpenChange(false));
|
||||
};
|
||||
|
||||
const handleImmediateShutdown = () => {
|
||||
connection?.shutdown(2).then(() => onOpenChange(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
type="custom"
|
||||
title={t("shutdown.title")}
|
||||
description={t("shutdown.description")}
|
||||
showFooter={false}
|
||||
>
|
||||
<div className="flex gap-2 p-4">
|
||||
<Input
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value, 10))}
|
||||
suffix={t("unit.minute.plural")}
|
||||
/>
|
||||
<Button className="w-24" onClick={handleScheduledShutdown}>
|
||||
<ClockIcon size={16} />
|
||||
</Button>
|
||||
<Button className="w-24" name="now" onClick={handleImmediateShutdown}>
|
||||
<PowerIcon className="mr-2" size={16} />
|
||||
{t("button.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useNodeDB } from "@core/stores";
|
||||
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,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
|
||||
export interface TracerouteResponseDialogProps {
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const TracerouteResponseDialog = ({
|
||||
traceroute,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useNodeDB();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||
const from = getNode(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet
|
||||
const fromLongName =
|
||||
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const fromShortName =
|
||||
from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName"));
|
||||
|
||||
const toUser = getNode(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet
|
||||
|
||||
if (!toUser || !from) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("tracerouteResponse.title", {
|
||||
identifier: `${fromLongName} (${fromShortName})`,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<TraceRoute
|
||||
route={route}
|
||||
routeBack={routeBack}
|
||||
from={{ user: from.user }}
|
||||
to={{ user: toUser.user }}
|
||||
snrTowards={snrTowards}
|
||||
snrBack={snrBack}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RouterRoleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
|
||||
const choosingTheRightDeviceRoleLink =
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/";
|
||||
|
||||
const handleCloseDialog = (action: "confirm" | "dismiss") => {
|
||||
setDialogOpen("unsafeRoles", false);
|
||||
setConfirmState(false);
|
||||
eventBus.emit("dialog:unsafeRoles", { action });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col">
|
||||
<DialogClose onClick={() => handleCloseDialog("dismiss")} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("unsafeRoles.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-md">
|
||||
{t("unsafeRoles.preamble")}
|
||||
<Link href={deviceRoleLink} className="">
|
||||
{t("unsafeRoles.deviceRoleDocumentation")}
|
||||
</Link>
|
||||
{t("unsafeRoles.conjunction")}
|
||||
<Link href={choosingTheRightDeviceRoleLink}>
|
||||
{t("unsafeRoles.choosingRightDeviceRole")}
|
||||
</Link>
|
||||
{t("unsafeRoles.postamble")}
|
||||
</DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<span className="dark:text-white">{t("unsafeRoles.confirmUnderstanding")}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="default" name="dismiss" onClick={() => handleCloseDialog("dismiss")}>
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
name="confirm"
|
||||
disabled={!confirmState}
|
||||
onClick={() => handleCloseDialog("confirm")}
|
||||
>
|
||||
{t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
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, describe, expect, it, type Mock, vi } from "vitest";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@core/utils/eventBus", () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 123 },
|
||||
},
|
||||
useDevice: () => ({
|
||||
setDialogOpen: mockDevice.setDialogOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useUnsafeRolesDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderUnsafeRolesHook = () => {
|
||||
return renderHook(() => useUnsafeRolesDialog());
|
||||
};
|
||||
|
||||
describe("handleCloseDialog", () => {
|
||||
it("should call setDialogOpen with correct parameters when dialog is closed", () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
result.current.handleCloseDialog();
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRoleSelection", () => {
|
||||
it("should resolve with true for safe roles without opening dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const safeRole = "SAFE_ROLE";
|
||||
|
||||
const validationResult = await result.current.validateRoleSelection(safeRole);
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]!);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", true);
|
||||
expect(eventBus.on).toHaveBeenCalledWith("dialog:unsafeRoles", expect.any(Function));
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "confirm" });
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
});
|
||||
|
||||
it("should resolve with false when user dismisses the dialog", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]!);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "dismiss" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
expect(validationResult).toBe(false);
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
});
|
||||
|
||||
it("should clean up event listener after response", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]!);
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
|
||||
onHandler({ action: "confirm" });
|
||||
await validationPromise;
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith("dialog:unsafeRoles", onHandler);
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with all unsafe roles", async () => {
|
||||
const { result } = renderUnsafeRolesHook();
|
||||
|
||||
for (const unsafeRole of UNSAFE_ROLES) {
|
||||
mockDevice.setDialogOpen.mockClear();
|
||||
(eventBus.on as Mock).mockClear();
|
||||
|
||||
const validationPromise = result.current.validateRoleSelection(unsafeRole);
|
||||
|
||||
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith("unsafeRoles", true);
|
||||
|
||||
const onHandler = (eventBus.on as Mock).mock.calls[0]![1];
|
||||
onHandler({ action: "confirm" });
|
||||
|
||||
const validationResult = await validationPromise;
|
||||
|
||||
expect(validationResult).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useDevice } from "@core/stores";
|
||||
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 const useUnsafeRolesDialog = () => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("unsafeRoles", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const validateRoleSelection = useCallback(
|
||||
(newRoleKey: string): Promise<boolean> => {
|
||||
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
setDialogOpen("unsafeRoles", true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
|
||||
eventBus.off("dialog:unsafeRoles", handleResponse);
|
||||
resolve(action === "confirm");
|
||||
};
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", handleResponse);
|
||||
});
|
||||
},
|
||||
[setDialogOpen],
|
||||
);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
validateRoleSelection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
export type DialogType = "confirm" | "alert" | "info" | "custom";
|
||||
|
||||
export interface BaseDialogConfig {
|
||||
type: DialogType;
|
||||
title: string;
|
||||
description?: string;
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogConfig extends BaseDialogConfig {
|
||||
type: "confirm";
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
icon?: ReactNode;
|
||||
confirmIcon?: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AlertDialogConfig extends BaseDialogConfig {
|
||||
type: "alert";
|
||||
dismissText?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface InfoDialogConfig extends BaseDialogConfig {
|
||||
type: "info";
|
||||
dismissText?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface CustomDialogConfig extends BaseDialogConfig {
|
||||
type: "custom";
|
||||
icon?: React.ReactNode;
|
||||
content: ReactNode;
|
||||
footerActions?: ReactNode;
|
||||
}
|
||||
|
||||
export type DialogConfig =
|
||||
| ConfirmDialogConfig
|
||||
| AlertDialogConfig
|
||||
| InfoDialogConfig
|
||||
| CustomDialogConfig;
|
||||
|
||||
export interface DialogState {
|
||||
isOpen: boolean;
|
||||
config?: DialogConfig;
|
||||
}
|
||||
|
||||
export interface UseDialogReturn {
|
||||
isOpen: boolean;
|
||||
config?: DialogConfig;
|
||||
openDialog: (config: DialogConfig) => void;
|
||||
closeDialog: () => void;
|
||||
handleConfirm: () => void;
|
||||
handleCancel: () => void;
|
||||
}
|
||||
|
||||
export const useDialog = (initialState?: DialogState): UseDialogReturn => {
|
||||
const [state, setState] = useState<DialogState>(initialState ?? { isOpen: false });
|
||||
|
||||
const openDialog = useCallback((config: DialogConfig) => {
|
||||
setState({ isOpen: true, config });
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
state.config?.onClose?.();
|
||||
setState({ isOpen: false, config: undefined });
|
||||
}, [state.config]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (state.config?.onConfirm) {
|
||||
await state.config.onConfirm();
|
||||
}
|
||||
closeDialog();
|
||||
}, [state.config, closeDialog]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
state.config?.onCancel?.();
|
||||
closeDialog();
|
||||
}, [state.config, closeDialog]);
|
||||
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
config: state.config,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultTexts = (type: DialogType, t: ReturnType<typeof useTranslation>["t"]) => {
|
||||
switch (type) {
|
||||
case "confirm":
|
||||
return {
|
||||
confirmText: t("button.confirm"),
|
||||
cancelText: t("button.cancel"),
|
||||
};
|
||||
case "alert":
|
||||
case "info":
|
||||
return {
|
||||
dismissText: t("button.dismiss"),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
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 { useEffect } from "react";
|
||||
import {
|
||||
type Control,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
get,
|
||||
type Path,
|
||||
type SubmitHandler,
|
||||
type UseFormReturn,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ZodType } from "zod/v4";
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
selector?: number;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseFormBuilderProps<T> {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
disabledBy?: DisabledBy<T>[];
|
||||
label: string;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
validationText?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GenericFormElementProps<T extends FieldValues, Y> {
|
||||
control: Control<T>;
|
||||
disabled?: boolean;
|
||||
field: Y;
|
||||
isDirty?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export interface DynamicFormProps<T extends FieldValues> {
|
||||
propMethods?: UseFormReturn<T, T, T>;
|
||||
onSubmit: SubmitHandler<T>;
|
||||
onFormInit?: DynamicFormFormInit<T>;
|
||||
submitType?: "onChange" | "onSubmit";
|
||||
hasSubmitButton?: boolean;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
values?: T;
|
||||
fieldGroups: {
|
||||
label: string;
|
||||
description: string;
|
||||
notes?: string;
|
||||
valid?: boolean;
|
||||
validationText?: string;
|
||||
fields: FieldProps<T>[];
|
||||
}[];
|
||||
validationSchema?: ZodType<T>;
|
||||
}
|
||||
|
||||
export type DynamicFormFormInit<T extends FieldValues> = (methods: UseFormReturn<T, T, T>) => void;
|
||||
|
||||
export function DynamicForm<T extends FieldValues>({
|
||||
propMethods,
|
||||
onSubmit,
|
||||
onFormInit,
|
||||
submitType = "onChange",
|
||||
hasSubmitButton,
|
||||
defaultValues,
|
||||
values,
|
||||
fieldGroups,
|
||||
validationSchema,
|
||||
}: DynamicFormProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const internalMethods = useForm<T>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues,
|
||||
resolver: validationSchema ? createZodResolver(validationSchema) : undefined,
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values,
|
||||
});
|
||||
|
||||
const methods = propMethods ?? internalMethods;
|
||||
|
||||
const { handleSubmit, control, getValues, formState, getFieldState } = methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (!propMethods) {
|
||||
onFormInit?.(internalMethods);
|
||||
}
|
||||
}, [onFormInit, propMethods, internalMethods]);
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[], disabled?: boolean): boolean => {
|
||||
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 (typeof value === "number") {
|
||||
return field.invert ? field.selector !== value : field.selector === value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className="space-y-8"
|
||||
{...(submitType === "onSubmit"
|
||||
? { onSubmit: handleSubmit(onSubmit) }
|
||||
: { onChange: handleSubmit(onSubmit) })}
|
||||
>
|
||||
{fieldGroups.map((fieldGroup) => (
|
||||
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
||||
<div>
|
||||
<Heading as="h4" className="font-medium">
|
||||
{fieldGroup.label}
|
||||
</Heading>
|
||||
<Subtle>{fieldGroup.description}</Subtle>
|
||||
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
|
||||
</div>
|
||||
|
||||
{fieldGroup.fields.map((field) => {
|
||||
const error = get(formState.errors, field.name as string);
|
||||
return (
|
||||
<FieldWrapper
|
||||
key={field.label}
|
||||
label={field.label}
|
||||
fieldName={field.name}
|
||||
description={field.description}
|
||||
valid={!error}
|
||||
validationText={
|
||||
error
|
||||
? String(
|
||||
t([`formValidation.${error.type}`, error.message], {
|
||||
returnObjects: false,
|
||||
...error.params,
|
||||
}),
|
||||
)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||
isDirty={getFieldState(field.name).isDirty}
|
||||
invalid={getFieldState(field.name).invalid}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && (
|
||||
<Button type="submit" variant="outline" disabled={!formState.isValid}>
|
||||
{t("button.submit")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { GenericInput, type InputFieldProps } from "@components/Form/FormInput.tsx";
|
||||
import {
|
||||
PasswordGenerator,
|
||||
type PasswordGeneratorProps,
|
||||
} from "@components/Form/FormPasswordGenerator.tsx";
|
||||
import { type SelectFieldProps, SelectInput } from "@components/Form/FormSelect.tsx";
|
||||
import { type ToggleFieldProps, 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>
|
||||
| SelectFieldProps<T>
|
||||
| MultiSelectFieldProps<T>
|
||||
| ToggleFieldProps<T>
|
||||
| PasswordGeneratorProps<T>;
|
||||
|
||||
export interface DynamicFormFieldProps<T extends FieldValues> {
|
||||
field: FieldProps<T>;
|
||||
control: Control<T>;
|
||||
disabled?: boolean;
|
||||
isDirty?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicFormField<T extends FieldValues>({
|
||||
field,
|
||||
control,
|
||||
disabled,
|
||||
isDirty,
|
||||
invalid,
|
||||
}: DynamicFormFieldProps<T>) {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
case "password":
|
||||
case "number":
|
||||
return <GenericInput field={field} control={control} disabled={disabled} isDirty={isDirty} />;
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleInput
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
isDirty={isDirty}
|
||||
invalid={invalid}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectInput
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
isDirty={isDirty}
|
||||
invalid={invalid}
|
||||
/>
|
||||
);
|
||||
case "passwordGenerator":
|
||||
return (
|
||||
<PasswordGenerator
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
isDirty={isDirty}
|
||||
invalid={invalid}
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return (
|
||||
<MultiSelectInput
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={disabled}
|
||||
isDirty={isDirty}
|
||||
invalid={invalid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
inputChange?: ChangeEventHandler;
|
||||
prefix?: string;
|
||||
properties?: {
|
||||
id?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
className?: string;
|
||||
fieldLength?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
currentValueLength?: number;
|
||||
showCharacterCount?: boolean;
|
||||
};
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function GenericInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field,
|
||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||
const { fieldLength, ...restProperties } = field.properties || {};
|
||||
|
||||
const {
|
||||
field: controllerField,
|
||||
fieldState: { error, isDirty },
|
||||
} = useController({
|
||||
name: field.name,
|
||||
control,
|
||||
rules: {
|
||||
minLength: field.properties?.fieldLength?.min,
|
||||
maxLength: field.properties?.fieldLength?.max,
|
||||
},
|
||||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (field.properties?.fieldLength?.max && newValue.length > field.properties.fieldLength.max) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.inputChange) {
|
||||
field.inputChange(e);
|
||||
}
|
||||
|
||||
controllerField.onChange(
|
||||
field.type === "number" ? Number.parseFloat(newValue).toString() : newValue,
|
||||
);
|
||||
};
|
||||
|
||||
const currentLength = controllerField.value ? String(controllerField.value).length : 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
||||
id={field.name}
|
||||
onChange={handleInputChange}
|
||||
showCopyButton={field.properties?.showCopyButton}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
variant={error ? "invalid" : isDirty ? "dirty" : "default"}
|
||||
/>
|
||||
|
||||
{fieldLength?.showCharacterCount && fieldLength.max && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
|
||||
{currentLength}/{fieldLength.max}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
|
||||
|
||||
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "multiSelect";
|
||||
placeholder?: string;
|
||||
onValueChange: (name: string) => void;
|
||||
isChecked: (name: string) => boolean;
|
||||
value: string[];
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: { [s: string]: string | number } | typeof FLAGS_CONFIG;
|
||||
formatEnumName?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function MultiSelectInput<T extends FieldValues>({
|
||||
field,
|
||||
isDirty,
|
||||
invalid,
|
||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||
const { t } = useTranslation("config");
|
||||
const { enumValue, className, ...remainingProperties } = field.properties;
|
||||
|
||||
const isNewConfigStructure =
|
||||
typeof Object.values(enumValue)[0] === "object" &&
|
||||
Object.values(enumValue)[0] !== null &&
|
||||
"i18nKey" in Object.values(enumValue)[0];
|
||||
|
||||
const optionsToRender = Object.entries(enumValue).map(([key, configOrValue]) => {
|
||||
if (isNewConfigStructure) {
|
||||
const config = configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
|
||||
return {
|
||||
key,
|
||||
display: t(config.i18nKey),
|
||||
value: config.value,
|
||||
};
|
||||
}
|
||||
return { key, display: key, value: configOrValue as number };
|
||||
});
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
className={cn([
|
||||
className,
|
||||
"rounded-md",
|
||||
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-5" : "",
|
||||
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-5" : "",
|
||||
])}
|
||||
{...remainingProperties}
|
||||
>
|
||||
{optionsToRender.map((option) => {
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={option.key}
|
||||
name={option.key}
|
||||
value={option.value.toString()}
|
||||
checked={field.isChecked(option.key)}
|
||||
onCheckedChange={() => field.onValueChange(option.key)}
|
||||
>
|
||||
{option.display}
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</MultiSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Generator } from "@components/UI/Generator.tsx";
|
||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||
|
||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "passwordGenerator";
|
||||
id: string;
|
||||
hide?: boolean;
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
devicePSKBitCount: number;
|
||||
inputChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
selectChange?: (event: string) => void;
|
||||
actionButtons: {
|
||||
text: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant: ButtonVariant;
|
||||
className?: string;
|
||||
}[];
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
}
|
||||
|
||||
export function PasswordGenerator<T extends FieldValues>({
|
||||
control,
|
||||
field,
|
||||
disabled,
|
||||
isDirty,
|
||||
invalid,
|
||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||
const { isVisible } = usePasswordVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: controllerField }) => (
|
||||
<Generator
|
||||
type={field.hide && !isVisible ? "password" : "text"}
|
||||
id={field.id}
|
||||
devicePSKBitCount={field.devicePSKBitCount}
|
||||
bits={field.bits}
|
||||
inputChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.inputChange?.(e); // call any external handler
|
||||
controllerField.onChange(value); // ensure RHF receives just the value
|
||||
}}
|
||||
selectChange={field.selectChange ?? (() => {})}
|
||||
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||
actionButtons={field.actionButtons}
|
||||
showPasswordToggle={field.showPasswordToggle}
|
||||
showCopyButton={field.showCopyButton}
|
||||
{...field.properties}
|
||||
{...controllerField}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { type FieldValues, useController } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
selectChange?: (e: string, name: string) => void;
|
||||
validate?: (newValue: string) => Promise<boolean>;
|
||||
defaultValue?: string;
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
defaultValue?: T;
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
};
|
||||
formatEnumName?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const formatEnumDisplay = (name: string): string => {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export function SelectInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field,
|
||||
isDirty,
|
||||
invalid,
|
||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||
const {
|
||||
field: { value, onChange, ref, onBlur, ...rest },
|
||||
} = useController({
|
||||
name: field.name,
|
||||
control,
|
||||
});
|
||||
|
||||
const { enumValue, formatEnumName, defaultValue, className, ...remainingProperties } =
|
||||
field.properties;
|
||||
const valueToKeyMap: Record<string, string> = {};
|
||||
const optionsEnumValues: [string, number][] = [];
|
||||
|
||||
if (enumValue) {
|
||||
Object.entries(enumValue).forEach(([key, val]) => {
|
||||
if (typeof val === "number") {
|
||||
valueToKeyMap[val.toString()] = key;
|
||||
optionsEnumValues.push([key, val]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleValueChange = async (newValue: string) => {
|
||||
const selectedKey = valueToKeyMap[newValue];
|
||||
if (!selectedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.validate) {
|
||||
const isValid = await field.validate(selectedKey);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.selectChange) {
|
||||
field.selectChange(newValue, selectedKey);
|
||||
}
|
||||
onChange(Number.parseInt(newValue, 10));
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
value={value?.toString()}
|
||||
defaultValue={defaultValue?.toString()}
|
||||
{...rest}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.name}
|
||||
className={cn([
|
||||
className,
|
||||
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
|
||||
invalid ? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2" : "",
|
||||
])}
|
||||
ref={ref}
|
||||
onBlur={onBlur}
|
||||
{...remainingProperties}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionsEnumValues.map(([name, val]) => (
|
||||
<SelectItem key={name} value={val.toString()}>
|
||||
{formatEnumName ? formatEnumDisplay(name) : name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "toggle";
|
||||
inputChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ToggleInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field,
|
||||
isDirty,
|
||||
invalid,
|
||||
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(v) => {
|
||||
onChange(v);
|
||||
field.inputChange?.(v);
|
||||
}}
|
||||
id={field.name}
|
||||
disabled={disabled}
|
||||
{...field.properties}
|
||||
className={cn([
|
||||
field.properties?.className,
|
||||
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
|
||||
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-2" : "",
|
||||
])}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
|
||||
export interface FieldWrapperProps {
|
||||
label: string;
|
||||
fieldName: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
valid?: boolean;
|
||||
validationText?: string;
|
||||
}
|
||||
|
||||
export const FieldWrapper = ({
|
||||
label,
|
||||
fieldName,
|
||||
description,
|
||||
children,
|
||||
valid,
|
||||
validationText,
|
||||
}: FieldWrapperProps) => (
|
||||
<div className="pt-6 sm:pt-5">
|
||||
<fieldset aria-labelledby="label-notifications">
|
||||
{/* first column = labels/heading, second column = fields, third column = gutter */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
|
||||
<Label htmlFor={fieldName}>{label}</Label>
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
|
||||
<p hidden={valid ?? true} className="text-sm text-red-500">
|
||||
{validationText}
|
||||
</p>
|
||||
<div className="mt-4 space-y-4 sm:col-span-2">
|
||||
<div className="flex items-center">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
FieldError,
|
||||
FieldValues,
|
||||
Resolver,
|
||||
ResolverOptions,
|
||||
ResolverResult,
|
||||
} from "react-hook-form";
|
||||
import type { ZodType } from "zod/v4";
|
||||
|
||||
export function createZodResolver<T extends FieldValues>(
|
||||
schema: ZodType<T, unknown>,
|
||||
): Resolver<T, unknown> {
|
||||
return (values: T, _context: unknown, _options?: ResolverOptions<T>): ResolverResult<T> => {
|
||||
const result = schema.safeParse(values);
|
||||
if (result.success) {
|
||||
return {
|
||||
values: result.data,
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
const errors: Record<string, FieldError & { params?: Record<string, unknown> }> = {};
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
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 newCode =
|
||||
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) + (suffix ? `.${suffix}` : "");
|
||||
|
||||
const fieldError: FieldError & { params?: Record<string, unknown> } = {
|
||||
type: newCode,
|
||||
message: message,
|
||||
...(Object.keys(params).length ? { params } : {}),
|
||||
};
|
||||
|
||||
if (!errors[key]) {
|
||||
errors[key] = fieldError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
values: {} as T,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const KeyBackupReminder = () => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
useBackupReminder({
|
||||
message: t("pkiBackupReminder.description"),
|
||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||
enabled: true,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { LangCode } from "@app/i18n-config.ts";
|
||||
import useLang from "@core/hooks/useLang.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { t } from "i18next";
|
||||
import { Check, Languages } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "./UI/Button.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./UI/DropdownMenu.tsx";
|
||||
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
disableHover?: boolean;
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ disableHover = false }: LanguageSwitcherProps) {
|
||||
const { i18n } = useTranslation("ui");
|
||||
const { set: setLanguage, current, getSupportedLangs } = useLang();
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
async (languageCode: LangCode) => {
|
||||
await setLanguage(languageCode, true);
|
||||
},
|
||||
[setLanguage],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"group flex items-center justify-start",
|
||||
"transition-colors duration-150 gap-2.5 p-1.5 rounded-md",
|
||||
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
<Languages
|
||||
size={16}
|
||||
className={cn(
|
||||
"text-gray-500 dark:text-gray-400 w-4 flex-shrink-0 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
)}
|
||||
/>
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm text-gray-600 dark:text-gray-100 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{`${t("languagePicker.label")}:`}
|
||||
</Subtle>
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-700 dark:text-gray-200 transition-colors duration-150",
|
||||
!disableHover && "group-hover:text-gray-900 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{current?.name}
|
||||
</Subtle>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-48">
|
||||
{getSupportedLangs.map((language) => (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{language.flag}</span>
|
||||
<span>{language.name}</span>
|
||||
</div>
|
||||
{i18n.language === language.code && <Check className="h-4 w-4 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MapGl, {
|
||||
AttributionControl,
|
||||
type MapLayerMouseEvent,
|
||||
type MapRef,
|
||||
NavigationControl,
|
||||
ScaleControl,
|
||||
} from "react-map-gl/maplibre";
|
||||
|
||||
interface MapProps {
|
||||
children?: React.ReactNode;
|
||||
onLoad?: (map: MapRef) => void;
|
||||
onMouseMove?: (event: MapLayerMouseEvent) => void;
|
||||
onClick?: (event: MapLayerMouseEvent) => void;
|
||||
interactiveLayerIds?: string[];
|
||||
initialViewState?: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
zoom?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const BaseMap = ({
|
||||
children,
|
||||
onLoad,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
interactiveLayerIds,
|
||||
initialViewState,
|
||||
}: MapProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useTranslation("map");
|
||||
|
||||
const darkMode = theme === "dark";
|
||||
const mapRef = useRef<MapRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (map && onLoad) {
|
||||
onLoad(map);
|
||||
}
|
||||
}, [onLoad]);
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return {
|
||||
"GeolocateControl.FindMyLocation": t("maplibre.GeolocateControl.FindMyLocation"),
|
||||
"NavigationControl.ZoomIn": t("maplibre.NavigationControl.ZoomIn"),
|
||||
"NavigationControl.ZoomOut": t("maplibre.NavigationControl.ZoomOut"),
|
||||
"ScaleControl.Meters": t("unit.meter.suffix"),
|
||||
"ScaleControl.Kilometers": t("unit.kilometer.suffix"),
|
||||
"CooperativeGesturesHandler.WindowsHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.WindowsHelpText",
|
||||
),
|
||||
"CooperativeGesturesHandler.MacHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.MacHelpText",
|
||||
),
|
||||
"CooperativeGesturesHandler.MobileHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.MobileHelpText",
|
||||
),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<MapGl
|
||||
ref={mapRef}
|
||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={
|
||||
initialViewState ?? {
|
||||
zoom: 1.8,
|
||||
latitude: 35,
|
||||
longitude: 0,
|
||||
}
|
||||
}
|
||||
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
|
||||
locale={locale}
|
||||
interactiveLayerIds={interactiveLayerIds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onClick}
|
||||
>
|
||||
<AttributionControl
|
||||
style={{
|
||||
background: darkMode ? "#ffffff" : undefined,
|
||||
color: darkMode ? "black" : undefined,
|
||||
}}
|
||||
/>
|
||||
{/* { Disabled for now until we can use i18n for the geolocate control} */}
|
||||
{/* <GeolocateControl
|
||||
position="top-right"
|
||||
i18nIsDynamicList
|
||||
positionOptions={{ enableHighAccuracy: true }}
|
||||
trackUserLocation
|
||||
/> */}
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
<ScaleControl />
|
||||
{children}
|
||||
</MapGl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import { type ChannelValidation, makeChannelSchema } from "@app/validation/channel.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type DefaultValues, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
onFormInit: DynamicFormFormInit<ChannelValidation>;
|
||||
channel: Protobuf.Channel.Channel;
|
||||
}
|
||||
|
||||
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
const { config, setChange, getChange, removeChange } = useDevice();
|
||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||
|
||||
const defaultConfig = channel;
|
||||
const defaultValues = {
|
||||
...defaultConfig,
|
||||
...{
|
||||
settings: {
|
||||
...defaultConfig?.settings,
|
||||
psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)),
|
||||
moduleSettings: {
|
||||
...defaultConfig?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
defaultConfig?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
? 10
|
||||
: defaultConfig?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workingChannel = getChange({
|
||||
type: "channel",
|
||||
index: channel.index,
|
||||
}) as Protobuf.Channel.Channel | undefined;
|
||||
const effectiveConfig = workingChannel ?? channel;
|
||||
const formValues = {
|
||||
...effectiveConfig,
|
||||
...{
|
||||
settings: {
|
||||
...effectiveConfig?.settings,
|
||||
psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)),
|
||||
moduleSettings: {
|
||||
...effectiveConfig?.settings?.moduleSettings,
|
||||
positionPrecision:
|
||||
effectiveConfig?.settings?.moduleSettings?.positionPrecision === undefined
|
||||
? 10
|
||||
: effectiveConfig?.settings?.moduleSettings?.positionPrecision,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(false);
|
||||
const [byteCount, setBytes] = useState<number>(effectiveConfig?.settings?.psk.length ?? 16);
|
||||
const ChannelValidationSchema = useMemo(() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
}, [byteCount]);
|
||||
|
||||
const formMethods = useForm<ChannelValidation>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues as DefaultValues<ChannelValidation>,
|
||||
resolver: createZodResolver(ChannelValidationSchema),
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values: formValues as ChannelValidation,
|
||||
});
|
||||
const { setValue, trigger, handleSubmit, formState } = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
onFormInit?.(formMethods);
|
||||
}, [onFormInit, formMethods]);
|
||||
|
||||
// Since byteCount is an independent state, we need to use the effective value
|
||||
// from the channel config to ensure the form updates when the setting changes
|
||||
const effectiveByteCount = effectiveConfig.settings?.psk.length ?? 16;
|
||||
const lastEffectiveRef = useRef<number>(effectiveByteCount);
|
||||
useEffect(() => {
|
||||
if (effectiveByteCount !== lastEffectiveRef.current) {
|
||||
lastEffectiveRef.current = effectiveByteCount;
|
||||
|
||||
setBytes(effectiveByteCount);
|
||||
trigger("settings.psk");
|
||||
}
|
||||
}, [effectiveByteCount, trigger]);
|
||||
|
||||
const onSubmit = (data: ChannelValidation) => {
|
||||
if (!formState.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = create(Protobuf.Channel.ChannelSchema, {
|
||||
...data,
|
||||
settings: {
|
||||
...data.settings,
|
||||
psk: toByteArray(data.settings.psk),
|
||||
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
||||
...data.settings.moduleSettings,
|
||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (deepCompareConfig(channel, payload, true)) {
|
||||
removeChange({ type: "channel", index: channel.index });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "channel", index: channel.index }, payload, channel);
|
||||
};
|
||||
|
||||
const preSharedKeyRegenerate = async () => {
|
||||
const newPsk = btoa(
|
||||
cryptoRandomString({
|
||||
length: byteCount ?? 16,
|
||||
type: "alphanumeric",
|
||||
}),
|
||||
);
|
||||
setValue("settings.psk", newPsk, { shouldDirty: true });
|
||||
setPreSharedDialogOpen(false);
|
||||
|
||||
const valid = await trigger("settings.psk");
|
||||
if (valid) {
|
||||
handleSubmit(onSubmit)(); // manually invoke form submit
|
||||
}
|
||||
};
|
||||
|
||||
const selectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e, 10);
|
||||
if (!Number.isNaN(count)) {
|
||||
setBytes(count);
|
||||
trigger("settings.psk");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<ChannelValidation>
|
||||
propMethods={formMethods}
|
||||
onSubmit={onSubmit}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("settings.label"),
|
||||
description: t("settings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "role",
|
||||
label: t("role.label"),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "settings.psk",
|
||||
id: "channel-psk",
|
||||
label: t("psk.label"),
|
||||
description: t("psk.description"),
|
||||
devicePSKBitCount: byteCount ?? 16,
|
||||
selectChange: selectChangeEvent,
|
||||
actionButtons: [
|
||||
{
|
||||
text: t("psk.generate"),
|
||||
variant: "success",
|
||||
onClick: () => setPreSharedDialogOpen(true),
|
||||
},
|
||||
],
|
||||
hide: true,
|
||||
properties: {
|
||||
showPasswordToggle: true,
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "settings.name",
|
||||
label: t("name.label"),
|
||||
description: t("name.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.uplinkEnabled",
|
||||
label: t("uplinkEnabled.label"),
|
||||
description: t("uplinkEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.downlinkEnabled",
|
||||
label: t("downlinkEnabled.label"),
|
||||
description: t("downlinkEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "settings.moduleSettings.positionPrecision",
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
text={{
|
||||
button: t("pkiRegenerateDialog.regenerate", { ns: "dialog" }),
|
||||
title: t("pkiRegenerateDialog.title", { ns: "dialog" }),
|
||||
description: t("pkiRegenerateDialog.description", { ns: "dialog" }),
|
||||
}}
|
||||
open={preSharedDialogOpen}
|
||||
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||
onSubmit={() => preSharedKeyRegenerate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Channel } from "@app/components/PageComponents/Channels/Channel";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import i18next from "i18next";
|
||||
import { QrCodeIcon, UploadIcon } from "lucide-react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigProps {
|
||||
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
|
||||
}
|
||||
|
||||
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
|
||||
return channel.settings?.name.length
|
||||
? channel.settings?.name
|
||||
: channel.index === 0
|
||||
? i18next.t("page.broadcastLabel")
|
||||
: i18next.t("page.channelIndex", {
|
||||
ns: "channels",
|
||||
index: channel.index,
|
||||
});
|
||||
};
|
||||
|
||||
export const Channels = ({ onFormInit }: ConfigProps) => {
|
||||
const { channels, hasChannelChange, setDialogOpen } = useDevice();
|
||||
const { t } = useTranslation("channels");
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const flags = useMemo(
|
||||
() => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])),
|
||||
[allChannels, hasChannelChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="channel_0">
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsTrigger
|
||||
key={`channel_${channel.index}`}
|
||||
value={`channel_${channel.index}`}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
{flags.get(channel.index) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<Button className="ml-auto mr-1 h-8" onClick={() => setDialogOpen("import", true)}>
|
||||
<UploadIcon className="mr-2" size={14} />
|
||||
{t("page.import")}
|
||||
</Button>
|
||||
<Button className=" h-8" onClick={() => setDialogOpen("QR", true)}>
|
||||
<QrCodeIcon className="mr-2" size={14} />
|
||||
{t("page.export")}
|
||||
</Button>
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={`channel_${channel.index}`} value={`channel_${channel.index}`}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<Channel key={channel.index} onFormInit={onFormInit} channel={channel} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@app/components/UI/Button";
|
||||
import type { Connection } from "@app/core/stores/deviceStore/types";
|
||||
|
||||
export function ConnectionStatusBadge({ status }: { status: Connection["status"] }) {
|
||||
let color = "";
|
||||
let displayStatus = status;
|
||||
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "configured":
|
||||
color = "bg-emerald-500";
|
||||
displayStatus = "connected";
|
||||
break;
|
||||
case "connecting":
|
||||
case "configuring":
|
||||
color = "bg-amber-500";
|
||||
break;
|
||||
case "online":
|
||||
color = "bg-blue-500";
|
||||
break;
|
||||
case "error":
|
||||
color = "bg-red-500";
|
||||
break;
|
||||
default:
|
||||
color = "bg-gray-400";
|
||||
}
|
||||
return (
|
||||
<Button variant="subtle" className="inline-flex items-center gap-2">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${color}`} aria-hidden="true" />
|
||||
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">{displayStatus}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { hasPos, toLngLat } from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Feature, FeatureCollection } from "geojson";
|
||||
import type { HeatmapLayerSpecification } from "maplibre-gl";
|
||||
import { useMemo } from "react";
|
||||
import { Layer, Source } from "react-map-gl/maplibre";
|
||||
|
||||
export type HeatmapMode = "density" | "snr";
|
||||
|
||||
export interface HeatmapLayerProps {
|
||||
id: string;
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[];
|
||||
mode: HeatmapMode;
|
||||
}
|
||||
|
||||
export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) => {
|
||||
const data: FeatureCollection = useMemo(() => {
|
||||
const features: Feature[] = filteredNodes
|
||||
.filter((node) => hasPos(node.position))
|
||||
.filter((node) => mode !== "snr" || node.snr !== undefined)
|
||||
.map((node) => ({
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: toLngLat(node.position),
|
||||
},
|
||||
properties: {
|
||||
snr: node.snr,
|
||||
name: node.user?.longName,
|
||||
shortName: node.user?.shortName,
|
||||
num: node.num,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}, [filteredNodes, mode]);
|
||||
|
||||
const paintProps: HeatmapLayerSpecification["paint"] = useMemo(
|
||||
() => ({
|
||||
"heatmap-weight":
|
||||
mode === "density" ? 1 : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1],
|
||||
"heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3],
|
||||
// Color ramp for heatmap. Domain is 0 (low) to 1 (high).
|
||||
// Begin color ramp at 0-stop with a 0-transparancy color
|
||||
// to create a blur-like effect.
|
||||
"heatmap-color": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["heatmap-density"],
|
||||
0,
|
||||
"rgba(33,102,172,0)",
|
||||
0.2,
|
||||
"rgb(103,169,207)",
|
||||
0.4,
|
||||
"rgb(209,229,240)",
|
||||
0.6,
|
||||
"rgb(253,219,199)",
|
||||
0.8,
|
||||
"rgb(239,138,98)",
|
||||
1,
|
||||
"rgb(178,24,43)",
|
||||
],
|
||||
"heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 2, 9, 20, 15, 30],
|
||||
// Opacity 0.7 to be visible but not blocking
|
||||
"heatmap-opacity": 0.7,
|
||||
}),
|
||||
[mode],
|
||||
);
|
||||
|
||||
return (
|
||||
<Source type="geojson" data={data}>
|
||||
<Layer id={id} type="heatmap" paint={paintProps} />
|
||||
<Layer
|
||||
id={`${id}-interaction`}
|
||||
type="circle"
|
||||
paint={{
|
||||
"circle-radius": 15,
|
||||
"circle-opacity": 0,
|
||||
"circle-stroke-opacity": 0,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
fanOutOffsetsPx,
|
||||
groupNodesByIdenticalCoords,
|
||||
type PxOffset,
|
||||
} from "@components/PageComponents/Map/cluster.ts";
|
||||
import {
|
||||
generatePrecisionCircles,
|
||||
SourcePrecisionCircles,
|
||||
} from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
||||
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx";
|
||||
import { StackBadge } from "@components/PageComponents/Map/Markers/StackBadge.tsx";
|
||||
import { NodeDetail } from "@components/PageComponents/Map/Popups/NodeDetail.tsx";
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
export interface NodeMarkerProps {
|
||||
mapRef: MapRef | undefined;
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[];
|
||||
myNode: Protobuf.Mesh.NodeInfo | undefined;
|
||||
expandedCluster: string | undefined;
|
||||
setExpandedCluster: (key: string | undefined) => void;
|
||||
popupState: PopupState | undefined;
|
||||
setPopupState: (state: PopupState | undefined) => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const NodesLayer = ({
|
||||
mapRef,
|
||||
filteredNodes,
|
||||
myNode,
|
||||
expandedCluster,
|
||||
setExpandedCluster,
|
||||
popupState,
|
||||
setPopupState,
|
||||
isVisible,
|
||||
}: NodeMarkerProps): React.ReactNode[] => {
|
||||
const { t } = useTranslation("map");
|
||||
|
||||
const { hasNodeError } = useNodeDB();
|
||||
const { focusLngLat } = useMapFitting(mapRef);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
popupState?.type !== "node"
|
||||
? undefined
|
||||
: (filteredNodes.find((node) => node.num === popupState.num) ?? undefined),
|
||||
[popupState, filteredNodes],
|
||||
);
|
||||
|
||||
const onMarkerClick = useCallback(
|
||||
(num: number, offset: PxOffset, e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
setPopupState({ type: "node", num, offset });
|
||||
const node = filteredNodes.find((node) => node.num === num) ?? undefined;
|
||||
if (node) {
|
||||
focusLngLat(toLngLat(node.position));
|
||||
}
|
||||
},
|
||||
[filteredNodes, focusLngLat, setPopupState],
|
||||
);
|
||||
|
||||
const clusters = groupNodesByIdenticalCoords(filteredNodes);
|
||||
const rendered: React.ReactNode[] = [];
|
||||
|
||||
for (const [key, nodes] of clusters) {
|
||||
if (!nodes.length || !nodes[0]?.position) {
|
||||
continue;
|
||||
}
|
||||
const [lng, lat] = toLngLat(nodes[0].position);
|
||||
const isExpanded = expandedCluster === key;
|
||||
|
||||
// Precompute pixel offsets for expanded state
|
||||
const expandedOffsets = isExpanded ? fanOutOffsetsPx(nodes.length, key) : undefined;
|
||||
|
||||
// Always render all node markers in the cluster
|
||||
for (const [i, node] of nodes.entries()) {
|
||||
const isHead = i === 0;
|
||||
|
||||
rendered.push(
|
||||
<NodeMarker
|
||||
key={`node-${key}-${node.num}`}
|
||||
id={node.num}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
offset={expandedOffsets?.[i]}
|
||||
label={node.user?.shortName ?? t("unknown.shortName")}
|
||||
tooltipLabel={node.user?.longName ?? t("unknown.longName")}
|
||||
hasError={hasNodeError(node.num)}
|
||||
isFavorite={node.isFavorite ?? false}
|
||||
isVisible={isVisible}
|
||||
onClick={(num, e) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
if (!isExpanded && !isHead) {
|
||||
// collapsed: tapping a buried marker expands the stack first
|
||||
setExpandedCluster(key);
|
||||
return;
|
||||
}
|
||||
onMarkerClick(num, expandedOffsets?.[i] ?? [0, 0], e);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length > 1) {
|
||||
rendered.push(
|
||||
<StackBadge
|
||||
key={`stack-badge-${key}`}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
count={nodes.length - 1}
|
||||
isVisible={isVisible && !isExpanded}
|
||||
onClick={(e) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
setExpandedCluster(key);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedNode) {
|
||||
rendered.push(
|
||||
<SourcePrecisionCircles
|
||||
key={`precision-circles-selected-${selectedNode.num}`}
|
||||
data={generatePrecisionCircles([selectedNode])}
|
||||
id={`precisionCircles-selected-${selectedNode.num}`}
|
||||
isVisible={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const [lng, lat] = toLngLat(selectedNode.position);
|
||||
|
||||
rendered.push(
|
||||
<PopupWrapper
|
||||
key={`popup-nodeinfo-${selectedNode.num}`}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
offset={popupState?.type === "node" ? popupState.offset : [0, 0]}
|
||||
onClose={() => setPopupState(undefined)}
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</PopupWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
if (myNode && hasPos(myNode.position)) {
|
||||
const [lng, lat] = toLngLat(myNode.position);
|
||||
rendered.push(
|
||||
<NodeMarker
|
||||
key={`node-${myNode.num}`}
|
||||
id={myNode.num}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
label={myNode.user?.shortName?.toString() ?? String(myNode.num)}
|
||||
tooltipLabel={t("myNode.tooltip")}
|
||||
hasError={false}
|
||||
isFavorite={true}
|
||||
onClick={(_, e) => onMarkerClick(myNode.num, [0, 0], e)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
|
||||
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { circle } from "@turf/turf";
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import { Layer, Source } from "react-map-gl/maplibre";
|
||||
|
||||
export interface PrecisionLayerProps {
|
||||
id: string;
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[];
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
type CircleProps = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
sortKey: number;
|
||||
};
|
||||
|
||||
export function generatePrecisionCircles(
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[],
|
||||
): FeatureCollection {
|
||||
const unique = new Map<
|
||||
string,
|
||||
{
|
||||
lng: number;
|
||||
lat: number;
|
||||
radiusM: number;
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
if (node.position?.precisionBits === undefined || node.position.precisionBits === 0) {
|
||||
continue;
|
||||
}
|
||||
const [lng, lat] = toLngLat(node.position);
|
||||
const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0);
|
||||
|
||||
const color = getColorFromNodeNum(node.num);
|
||||
const isLight = isLightColor(color);
|
||||
|
||||
const key = `${lat},${lng}:${radiusM}`;
|
||||
|
||||
if (!unique.has(key)) {
|
||||
unique.set(key, {
|
||||
lng,
|
||||
lat,
|
||||
radiusM,
|
||||
r: color.r,
|
||||
g: color.g,
|
||||
b: color.b,
|
||||
a: isLight ? 0.3 : 0.2, // light colors need more alpha to be as visible
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(unique.values()).sort((a, b) => a.radiusM - b.radiusM);
|
||||
|
||||
const features: Feature<Polygon, CircleProps>[] = items.map(
|
||||
({ lng, lat, radiusM, r, g, b, a }) => {
|
||||
const feat = circle([lng, lat], radiusM, {
|
||||
steps: 64,
|
||||
units: "meters",
|
||||
}) as Feature<Polygon, CircleProps>;
|
||||
feat.properties = { r, g, b, a, sortKey: -radiusM };
|
||||
return feat;
|
||||
},
|
||||
);
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
export const SourcePrecisionCircles = ({
|
||||
data,
|
||||
id,
|
||||
isVisible,
|
||||
}: {
|
||||
data: FeatureCollection;
|
||||
id: string;
|
||||
isVisible: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Source id={id} type="geojson" data={data}>
|
||||
<Layer
|
||||
id={`${id}-fill`}
|
||||
type="fill"
|
||||
layout={{ visibility: isVisible ? "visible" : "none" }}
|
||||
paint={{
|
||||
"fill-color": ["rgba", ["get", "r"], ["get", "g"], ["get", "b"], ["get", "a"]],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id={`${id}-line`}
|
||||
type="line"
|
||||
layout={{ visibility: isVisible ? "visible" : "none" }}
|
||||
paint={{
|
||||
"line-color": ["rgba", 255, 255, 255, 0.5],
|
||||
"line-width": 2,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
);
|
||||
};
|
||||
|
||||
export const PrecisionLayer = ({
|
||||
id,
|
||||
filteredNodes,
|
||||
isVisible,
|
||||
}: PrecisionLayerProps): React.ReactNode => {
|
||||
return (
|
||||
<SourcePrecisionCircles
|
||||
data={generatePrecisionCircles(filteredNodes)}
|
||||
id={id}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import { Mono } from "@app/components/generic/Mono";
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
import { getSignalColor } from "@app/core/utils/signalColor";
|
||||
import type { VisibilityState } from "@components/PageComponents/Map/Tools/MapLayerTool";
|
||||
import { useDevice } from "@core/stores";
|
||||
import {
|
||||
distanceMeters,
|
||||
hasPos,
|
||||
type LngLat,
|
||||
lngLatToMercator,
|
||||
mercatorToLngLat,
|
||||
toLngLat,
|
||||
} from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { Feature, FeatureCollection } from "geojson";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layer, Source } from "react-map-gl/maplibre";
|
||||
|
||||
const ARC_SEGMENTS = 32;
|
||||
const ARC_OFFSET = 0.01; // 1% of distance
|
||||
const MIN_LEN = 1e-3; // meters
|
||||
|
||||
export interface SNRLayerProps {
|
||||
id: string;
|
||||
filteredNodes: Protobuf.Mesh.NodeInfo[];
|
||||
myNode: Protobuf.Mesh.NodeInfo | undefined;
|
||||
visibilityState: VisibilityState;
|
||||
}
|
||||
|
||||
export interface SNRTooltipProps {
|
||||
pos: { x: number; y: number };
|
||||
snr: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
type NeighborPlus = Protobuf.Mesh.Neighbor & {
|
||||
num: number | undefined;
|
||||
position: Protobuf.Mesh.Position | undefined;
|
||||
};
|
||||
type NeighborInfoPlus = Omit<Protobuf.Mesh.NeighborInfo, "neighbors"> & {
|
||||
neighbors: NeighborPlus[];
|
||||
};
|
||||
|
||||
type RemoteInfo = {
|
||||
type: "remote";
|
||||
node: Protobuf.Mesh.NodeInfo;
|
||||
neighborInfo: NeighborInfoPlus;
|
||||
};
|
||||
|
||||
type DirectInfo = {
|
||||
type: "direct";
|
||||
from: Protobuf.Mesh.NodeInfo;
|
||||
to: Protobuf.Mesh.NodeInfo;
|
||||
snr: number;
|
||||
};
|
||||
|
||||
type NeighborInfos = RemoteInfo | DirectInfo;
|
||||
|
||||
type Pair = {
|
||||
a: number;
|
||||
b: number;
|
||||
ab?: number; // SNR a->b
|
||||
ba?: number; // SNR b->a
|
||||
};
|
||||
|
||||
function arcSegment(aLngLat: LngLat, bLngLat: LngLat, curved: boolean): LngLat[] | undefined {
|
||||
if (!curved && distanceMeters(aLngLat, bLngLat) < MIN_LEN) {
|
||||
// Straight line
|
||||
return [aLngLat, bLngLat];
|
||||
}
|
||||
const [ax, ay] = lngLatToMercator(aLngLat);
|
||||
const [bx, by] = lngLatToMercator(bLngLat);
|
||||
|
||||
const dx = bx - ax,
|
||||
dy = by - ay;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < MIN_LEN) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offsetMeters = curved ? len * ARC_OFFSET : 0;
|
||||
|
||||
// Unit direction A->B and its left-hand normal
|
||||
const ux = dx / len,
|
||||
uy = dy / len;
|
||||
const nx = -uy,
|
||||
ny = ux;
|
||||
|
||||
// Control point at the midpoint, offset to the left by offsetMeters
|
||||
const mx = (ax + bx) * 0.5,
|
||||
my = (ay + by) * 0.5;
|
||||
const cx = mx + nx * offsetMeters,
|
||||
cy = my + ny * offsetMeters;
|
||||
|
||||
const coords: LngLat[] = [];
|
||||
for (let i = 0; i <= ARC_SEGMENTS; i++) {
|
||||
const t = i / ARC_SEGMENTS;
|
||||
const omt = 1 - t;
|
||||
// Quadratic Bézier: B(t) = (1−t)^2*A + 2(1−t)t*C + t^2*B
|
||||
const x = omt * omt * ax + 2 * omt * t * cx + t * t * bx;
|
||||
const y = omt * omt * ay + 2 * omt * t * cy + t * t * by;
|
||||
coords.push(mercatorToLngLat([x, y]));
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
function upsertPair(
|
||||
fromId: number,
|
||||
toId: number,
|
||||
snr: number,
|
||||
pairs: Map<string, Pair>,
|
||||
idToLngLat: Map<number, LngLat>,
|
||||
): void {
|
||||
if (fromId === toId || !idToLngLat.has(fromId) || !idToLngLat.has(toId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const a = Math.min(fromId, toId);
|
||||
const b = Math.max(fromId, toId);
|
||||
const key = `${a}-${b}`;
|
||||
|
||||
let pair = pairs.get(key); // pair might exist
|
||||
if (!pair) {
|
||||
pair = { a, b };
|
||||
}
|
||||
|
||||
// Store best SNR for each direction
|
||||
if (fromId === a) {
|
||||
pair.ab = pair.ab && pair.ab > snr ? pair.ab : snr;
|
||||
} else {
|
||||
pair.ba = pair.ba && pair.ba > snr ? pair.ba : snr;
|
||||
}
|
||||
|
||||
pairs.set(key, pair);
|
||||
}
|
||||
|
||||
function makeFeature(
|
||||
fromId: number,
|
||||
toId: number,
|
||||
fromPos: LngLat,
|
||||
toPos: LngLat,
|
||||
snr: number,
|
||||
curved: boolean,
|
||||
): Feature | undefined {
|
||||
const segment = arcSegment(fromPos, toPos, curved);
|
||||
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: segment },
|
||||
properties: {
|
||||
color: getSignalColor(snr),
|
||||
snr,
|
||||
from: fromId,
|
||||
to: toId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pushIfFeature(
|
||||
a: number,
|
||||
b: number,
|
||||
aPos: LngLat,
|
||||
bPos: LngLat,
|
||||
snr: number,
|
||||
curved: boolean,
|
||||
features: Feature[],
|
||||
) {
|
||||
const feat = makeFeature(a, b, aPos, bPos, snr, curved);
|
||||
if (feat) {
|
||||
features.push(feat);
|
||||
}
|
||||
}
|
||||
|
||||
function generateNeighborLines(neighborInfos: NeighborInfos[]): FeatureCollection {
|
||||
// Collect positions for all referenced nodes, discard pairs with missing positions
|
||||
const idToLngLat = new Map<number, LngLat>();
|
||||
const ensure = (node?: Protobuf.Mesh.NodeInfo | NeighborPlus) => {
|
||||
if (node?.num && hasPos(node.position) && !idToLngLat.has(node.num)) {
|
||||
idToLngLat.set(node.num, toLngLat(node.position));
|
||||
}
|
||||
};
|
||||
|
||||
for (const info of neighborInfos) {
|
||||
if (info.type === "remote") {
|
||||
ensure(info.node);
|
||||
for (const neighbor of info.neighborInfo.neighbors) {
|
||||
ensure(neighbor);
|
||||
}
|
||||
} else {
|
||||
ensure(info.from);
|
||||
ensure(info.to);
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesce into pairs
|
||||
const pairs = new Map<string, Pair>();
|
||||
for (const info of neighborInfos) {
|
||||
if (info.type === "remote") {
|
||||
// RemoteInfo object
|
||||
const fromId = info.node.num;
|
||||
for (const neighbor of info.neighborInfo.neighbors) {
|
||||
upsertPair(fromId, neighbor.nodeId, neighbor.snr, pairs, idToLngLat);
|
||||
}
|
||||
} else {
|
||||
// DirectInfo object
|
||||
upsertPair(info.from.num, info.to.num, info.snr, pairs, idToLngLat);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate features
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const pair of pairs.values()) {
|
||||
const aPos = idToLngLat.get(pair.a);
|
||||
const bPos = idToLngLat.get(pair.b);
|
||||
if (!aPos || !bPos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.ab && pair.ba) {
|
||||
// both directions → two arcs
|
||||
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, true, features);
|
||||
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, true, features);
|
||||
} else {
|
||||
// only one direction → straight
|
||||
if (pair.ab) {
|
||||
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, false, features);
|
||||
}
|
||||
if (pair.ba) {
|
||||
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, false, features);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
export const SNRTooltip = ({ pos, snr, from, to }: Partial<SNRTooltipProps> = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!pos) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute block p-2 px-3 text-sm bg-white dark:bg-slate-800 rounded-lg shadow",
|
||||
"",
|
||||
)}
|
||||
style={{ left: `${pos.x + 5}px`, top: `${pos.y + 10}px` }}
|
||||
aria-hidden={!pos}
|
||||
>
|
||||
<div>
|
||||
<strong className="font-bold">{from ?? ""}</strong>
|
||||
{to && (
|
||||
<>
|
||||
<span className="mx-1">⭢</span>
|
||||
<strong className="font-bold">{to ?? ""}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SNRLayer = ({
|
||||
id,
|
||||
filteredNodes,
|
||||
myNode,
|
||||
visibilityState,
|
||||
}: SNRLayerProps): React.ReactNode => {
|
||||
const { getNeighborInfo } = useDevice();
|
||||
|
||||
const remotePairs = visibilityState.remoteNeighbors
|
||||
? filteredNodes.flatMap((node) => {
|
||||
const neighborInfo = getNeighborInfo(node.num);
|
||||
return neighborInfo
|
||||
? [
|
||||
{
|
||||
type: "remote" as const,
|
||||
node,
|
||||
neighborInfo: {
|
||||
...neighborInfo,
|
||||
neighbors: neighborInfo.neighbors.map((n) => {
|
||||
const node = filteredNodes.find((node) => node.num === n.nodeId);
|
||||
return { ...n, num: node?.num, position: node?.position };
|
||||
}),
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: [];
|
||||
|
||||
const directPairs =
|
||||
visibilityState.directNeighbors && myNode
|
||||
? filteredNodes
|
||||
.filter((node) => node.hopsAway === 0 && node.num !== myNode.num)
|
||||
.map((to) => ({
|
||||
type: "direct" as const,
|
||||
from: myNode,
|
||||
to,
|
||||
snr: to.snr ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const featureCollection = generateNeighborLines([...remotePairs, ...directPairs]);
|
||||
|
||||
return (
|
||||
<Source type="geojson" data={featureCollection}>
|
||||
<Layer
|
||||
id={id}
|
||||
type="line"
|
||||
paint={{
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx";
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting";
|
||||
import { useDevice, type WaypointWithMetadata } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
export interface WaypointLayerProps {
|
||||
mapRef: MapRef | undefined;
|
||||
myNode: Protobuf.Mesh.NodeInfo | undefined;
|
||||
isVisible: boolean;
|
||||
popupState: PopupState | undefined;
|
||||
setPopupState: (state: PopupState | undefined) => void;
|
||||
}
|
||||
|
||||
import { toLngLat } from "@core/utils/geo.ts";
|
||||
|
||||
export const WaypointLayer = ({
|
||||
mapRef,
|
||||
myNode,
|
||||
isVisible,
|
||||
popupState,
|
||||
setPopupState,
|
||||
}: WaypointLayerProps): React.ReactNode[] => {
|
||||
const { waypoints } = useDevice();
|
||||
const { focusLngLat } = useMapFitting(mapRef);
|
||||
|
||||
const onMarkerClick = useCallback(
|
||||
(waypoint: WaypointWithMetadata, e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent?.stopPropagation();
|
||||
setPopupState({ type: "waypoint", waypoint });
|
||||
if (waypoint.longitudeI && waypoint.latitudeI) {
|
||||
focusLngLat(
|
||||
toLngLat({
|
||||
longitudeI: waypoint.longitudeI,
|
||||
latitudeI: waypoint.latitudeI,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[focusLngLat, setPopupState],
|
||||
);
|
||||
|
||||
const rendered: React.ReactNode[] = [];
|
||||
if (!isVisible) {
|
||||
return rendered;
|
||||
}
|
||||
|
||||
for (const waypoint of waypoints) {
|
||||
const [lng, lat] = toLngLat({
|
||||
latitudeI: waypoint.latitudeI,
|
||||
longitudeI: waypoint.longitudeI,
|
||||
});
|
||||
rendered.push(
|
||||
<NodeMarker
|
||||
key={`waypoint-${waypoint.id}`}
|
||||
id={waypoint.id}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
label={String.fromCodePoint(waypoint.icon) ?? "📍"}
|
||||
longLabel={waypoint.name}
|
||||
avatarClassName="bg-amber-400 border-amber-500"
|
||||
onClick={(_, e) => onMarkerClick(waypoint, e)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (popupState?.type === "waypoint") {
|
||||
const [lng, lat] = toLngLat({
|
||||
latitudeI: popupState.waypoint.latitudeI,
|
||||
longitudeI: popupState.waypoint.longitudeI,
|
||||
});
|
||||
|
||||
rendered.push(
|
||||
<PopupWrapper
|
||||
key={`popup-waypoint-${popupState.waypoint.id}`}
|
||||
lng={lng}
|
||||
lat={lat}
|
||||
offset={[0, 25]}
|
||||
onClose={() => setPopupState(undefined)}
|
||||
>
|
||||
<WaypointDetail waypoint={popupState.waypoint} myNode={myNode} onEdit={() => {}} />
|
||||
</PopupWrapper>,
|
||||
);
|
||||
}
|
||||
return rendered;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { memo } from "react";
|
||||
import { Marker } from "react-map-gl/maplibre";
|
||||
|
||||
export const NodeMarker = memo(function NodeMarker({
|
||||
id,
|
||||
lng,
|
||||
lat,
|
||||
longLabel,
|
||||
tooltipLabel,
|
||||
hasError,
|
||||
isFavorite,
|
||||
offset,
|
||||
avatarClassName,
|
||||
isVisible = true,
|
||||
onClick,
|
||||
}: {
|
||||
id: number;
|
||||
lng: number;
|
||||
lat: number;
|
||||
label: string;
|
||||
longLabel?: string;
|
||||
tooltipLabel?: string;
|
||||
hasError?: boolean;
|
||||
isFavorite?: boolean;
|
||||
offset?: PxOffset;
|
||||
avatarClassName?: string;
|
||||
isVisible?: boolean;
|
||||
onClick: (id: number, e: { originalEvent: MouseEvent }) => void;
|
||||
}) {
|
||||
const [dx, dy] = offset ?? [0, 0];
|
||||
|
||||
const style = {
|
||||
"--dx": `${dx}px`,
|
||||
"--dy": `${dy}px`,
|
||||
pointerEvents: "auto",
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
anchor="bottom"
|
||||
style={{
|
||||
top: "20px",
|
||||
pointerEvents: "none",
|
||||
display: isVisible ? "" : "none",
|
||||
}}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="will-change-transform cursor-pointer animate-fan-out"
|
||||
style={style}
|
||||
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
|
||||
>
|
||||
<Avatar
|
||||
nodeNum={id}
|
||||
className={cn(
|
||||
"border-[1.5px] border-slate-600 shadow-m shadow-slate-600",
|
||||
avatarClassName,
|
||||
)}
|
||||
showError={hasError}
|
||||
showFavorite={isFavorite}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
{tooltipLabel && ( // only show tooltip if there's a label
|
||||
<TooltipContent
|
||||
className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs cursor-pointer"
|
||||
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
|
||||
>
|
||||
{tooltipLabel}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
)}
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{longLabel && ( // only show label if there's a longLabel
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-16 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-white/70 px-2 py-0.5 text-xs text-slate-900 backdrop-blur-xs cursor-pointer"
|
||||
style={style}
|
||||
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
|
||||
>
|
||||
{longLabel}
|
||||
</button>
|
||||
)}
|
||||
</Marker>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Marker } from "react-map-gl/maplibre";
|
||||
|
||||
type StackBadgeProps = {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number; // n = size-1
|
||||
isVisible?: boolean;
|
||||
onClick: (e: { originalEvent: MouseEvent }) => void;
|
||||
};
|
||||
|
||||
export const StackBadge = ({ lng, lat, count, isVisible = true, onClick }: StackBadgeProps) => {
|
||||
return (
|
||||
<Marker
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
style={{ left: 18, top: -18, display: isVisible ? "" : "none" }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => onClick({ originalEvent: e.nativeEvent })}
|
||||
className="rounded-full bg-blue-600 text-white text-xs px-2 py-0.5 shadow ring-1 ring-black/20 active:bg-red-800"
|
||||
type={"button"}
|
||||
>
|
||||
+{count}
|
||||
</button>
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
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/Separator.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 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, LockOpenIcon, MessageSquareIcon, MountainSnow, Star } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("nodes");
|
||||
const name = node.user?.longName ?? t("unknown.shortName");
|
||||
const shortName = node.user?.shortName ?? t("unknown.shortName");
|
||||
const hwModel = node.user?.hwModel ?? 0;
|
||||
const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as
|
||||
| keyof typeof Protobuf.Mesh.HardwareModel
|
||||
| undefined;
|
||||
const hardwareType = rawHardwareType
|
||||
? rawHardwareType === "UNSET"
|
||||
? t("unset")
|
||||
: rawHardwareType.replaceAll("_", " ")
|
||||
: `${hwModel}`;
|
||||
// SNR is in dB; derive a 0–100% quality heuristic (-10 dB → 0%, +10 dB → 100%).
|
||||
const snrQuality = Math.min(Math.max(Math.round((node.snr + 10) * 5), 0), 100);
|
||||
const snrTone =
|
||||
snrQuality >= 67 ? "text-green-600" : snrQuality >= 34 ? "text-yellow-600" : "text-red-600";
|
||||
function handleDirectMessage() {
|
||||
navigate({ to: `/messages/direct/${node.num}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-1 text-slate-900">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar nodeNum={node.num} size="sm" />
|
||||
|
||||
<div
|
||||
onFocusCapture={(e) => {
|
||||
// Required to prevent DM tooltip auto-appearing on creation
|
||||
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("nodeDetail.publicKeyEnabled.label")}
|
||||
/>
|
||||
) : (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label={t("nodeDetail.noPublicKey.label")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MessageSquareIcon
|
||||
size={14}
|
||||
onClick={handleDirectMessage}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{t("nodeDetail.directMessage.label", {
|
||||
shortName,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={
|
||||
node.isFavorite ? t("nodeDetail.favorite.label") : t("nodeDetail.notFavorite.label")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Heading as="h5">{name}</Heading>
|
||||
{hardwareType !== t("unset") && <Subtle>{hardwareType}</Subtle>}
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<BatteryStatus deviceMetrics={node.deviceMetrics} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
|
||||
{node.user?.id && <div>{node.user?.id}</div>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-1"
|
||||
title={new Date(node.lastHeard * 1000).toLocaleString(navigator.language)}
|
||||
>
|
||||
<div>
|
||||
{node.lastHeard > 0 && (
|
||||
<div>
|
||||
{t("nodeDetail.status.heard")} <TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{node.viaMqtt && (
|
||||
<div style={{ color: "#660066" }} className="font-medium">
|
||||
{t("nodeDetail.status.mqtt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-1" />
|
||||
|
||||
<div className="flex mt-2 text-sm">
|
||||
<div className="flex items-center grow">
|
||||
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
|
||||
{Number.isNaN(node.hopsAway) ? t("unit.hopsAway.unknown") : node.hopsAway}
|
||||
</div>
|
||||
<div>{node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}</div>
|
||||
</div>
|
||||
{node.position?.altitude && (
|
||||
<div className="flex items-center grow">
|
||||
<MountainSnow
|
||||
size={15}
|
||||
className="ml-2 mr-1"
|
||||
aria-label={t("nodeDetail.elevation.label")}
|
||||
/>
|
||||
<div>
|
||||
{formatQuantity(node.position?.altitude, {
|
||||
one: t("unit.meter.one"),
|
||||
other: t("unit.meter.plural"),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex mt-2">
|
||||
{!!node.deviceMetrics?.channelUtilization && (
|
||||
<div className="grow">
|
||||
<div>{t("channelUtilization.short")}</div>
|
||||
<Mono>{node.deviceMetrics?.channelUtilization.toPrecision(3)}%</Mono>
|
||||
</div>
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>{t("airtimeUtilization.short")}</div>
|
||||
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.snr !== 0 && (
|
||||
<div className="mt-2">
|
||||
<div>{t("unit.snr")}</div>
|
||||
<Mono className="flex items-center gap-1 text-xs">
|
||||
<span className={snrTone}>
|
||||
{Number(node.snr.toFixed(1))} {t("unit.db")}
|
||||
</span>
|
||||
<Dot className="text-gray-400" />
|
||||
<span className="text-gray-500">{snrQuality}%</span>
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
|
||||
import type { WaypointWithMetadata } from "@core/stores";
|
||||
import { memo } from "react";
|
||||
import { Popup } from "react-map-gl/maplibre";
|
||||
|
||||
export type PopupState =
|
||||
| { type: "node"; num: number; offset: PxOffset }
|
||||
| { type: "waypoint"; waypoint: WaypointWithMetadata };
|
||||
|
||||
export const PopupWrapper = memo(function SelectedNodePopup({
|
||||
lng,
|
||||
lat,
|
||||
offset,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
lng: number;
|
||||
lat: number;
|
||||
offset?: PxOffset;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Popup
|
||||
anchor="top"
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
onClose={onClose}
|
||||
className="w-full"
|
||||
style={{
|
||||
left: `${offset?.[0] ?? 0}px`,
|
||||
top: `${(offset?.[1] ?? 0) + 22}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Popup>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { TimeAgo } from "@components/generic/TimeAgo";
|
||||
import { Separator } from "@components/UI/Separator.tsx";
|
||||
import type { WaypointWithMetadata } from "@core/stores";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { bearingDegrees, distanceMeters, hasPos, toLngLat } from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ClockPlusIcon,
|
||||
CompassIcon,
|
||||
MapPinnedIcon,
|
||||
MoveHorizontalIcon,
|
||||
NavigationIcon,
|
||||
RotateCwIcon,
|
||||
UserLockIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface WaypointDetailProps {
|
||||
waypoint: WaypointWithMetadata;
|
||||
myNode?: Protobuf.Mesh.NodeInfo;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
|
||||
const { t } = useTranslation("map");
|
||||
const { getNode } = useNodeDB();
|
||||
|
||||
const waypointLngLat = toLngLat({
|
||||
latitudeI: waypoint.latitudeI,
|
||||
longitudeI: waypoint.longitudeI,
|
||||
});
|
||||
|
||||
const distance = hasPos(myNode?.position)
|
||||
? distanceMeters(toLngLat(myNode?.position), waypointLngLat)
|
||||
: undefined;
|
||||
|
||||
const bearing = hasPos(myNode?.position)
|
||||
? bearingDegrees(toLngLat(myNode?.position), waypointLngLat)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={`wp-${waypoint.id}-title`}
|
||||
className="flex flex-col gap-2 px-1 text-sm dark:text-slate-900"
|
||||
>
|
||||
<header className="flex items-center my-1 justify-between">
|
||||
<h3
|
||||
id={`wp-${waypoint.id}-title`}
|
||||
className="flex items-center gap-2 font-semibold text-slate-900"
|
||||
>
|
||||
<span aria-hidden>{String.fromCodePoint(waypoint.icon) ?? "📍"}</span>
|
||||
<span>{waypoint.name}</span>
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
{waypoint.description && (
|
||||
<p className="inline-flex items-center gap-1">{waypoint.description}</p>
|
||||
)}
|
||||
|
||||
<Separator className="dark:bg-slate-200" role="separator" />
|
||||
|
||||
<section aria-label={t("waypointDetail.details")}>
|
||||
<dl className="space-y-1.5">
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-top gap-2 text-slate-500 min-w-0">
|
||||
<MapPinnedIcon size={14} aria-hidden className="mt-1" />
|
||||
<span className="truncate">
|
||||
{t("waypointDetail.longitude")}
|
||||
<br />
|
||||
{t("waypointDetail.latitude")}
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<data value={waypointLngLat[0]}>{waypointLngLat[0]}</data>
|
||||
<br />
|
||||
<data value={waypointLngLat[1]}>{waypointLngLat[1]}</data>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Created */}
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<ClockPlusIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.createdDate")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<time dateTime={new Date(waypoint.metadata.created).toISOString()}>
|
||||
<TimeAgo timestamp={waypoint.metadata.created} />
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Updated */}
|
||||
{waypoint.metadata.updated && (
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<RotateCwIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.updated")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<time dateTime={new Date(waypoint.metadata.updated).toISOString()}>
|
||||
<TimeAgo timestamp={waypoint.metadata.updated} />
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expires */}
|
||||
{waypoint.expire !== 0 && (
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<ClockFadingIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.expires")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<time dateTime={new Date(waypoint.expire * 1000).toISOString()}>
|
||||
<TimeAgo timestamp={waypoint.expire * 1000} />
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Distance */}
|
||||
{distance != null && (
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<MoveHorizontalIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.distance")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
<data value={Math.round(distance)}>
|
||||
{Math.round(distance)}{" "}
|
||||
{distance === 1 ? t("unit.meter.one") : t("unit.meter.plural")}
|
||||
</data>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bearing */}
|
||||
{bearing != null && (
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<CompassIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.bearing")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right inline-flex items-center ">
|
||||
<NavigationIcon
|
||||
size={16}
|
||||
aria-hidden
|
||||
className="shrink-0 origin-center transition-transform mr-2"
|
||||
style={{ transform: `rotate(${bearing - 45}deg)` }}
|
||||
/>
|
||||
<data value={Math.round(bearing)}>{Math.round(bearing)}</data>
|
||||
<span aria-hidden>{t("unit.degree.suffix")}</span>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Locked To */}
|
||||
{waypoint.lockedTo != null && waypoint.lockedTo !== 0 && (
|
||||
<div className="flex flex-wrap items-start gap-x-3">
|
||||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0">
|
||||
<UserLockIcon size={14} aria-hidden />
|
||||
<span className="truncate">{t("waypointDetail.lockedTo")}</span>
|
||||
</dt>
|
||||
<dd className="ms-auto text-right">
|
||||
{getNode(waypoint.lockedTo)?.user?.longName ?? t("unknown.longName")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { HeatmapMode } from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/UI/Popover.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LayersIcon } from "lucide-react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface VisibilityState {
|
||||
nodeMarkers: boolean;
|
||||
directNeighbors: boolean;
|
||||
remoteNeighbors: boolean;
|
||||
positionPrecision: boolean;
|
||||
traceroutes: boolean;
|
||||
waypoints: boolean;
|
||||
heatmap: boolean;
|
||||
}
|
||||
|
||||
export const defaultVisibilityState: VisibilityState = {
|
||||
nodeMarkers: true,
|
||||
directNeighbors: false,
|
||||
remoteNeighbors: false,
|
||||
positionPrecision: false,
|
||||
traceroutes: false,
|
||||
waypoints: true,
|
||||
heatmap: false,
|
||||
};
|
||||
|
||||
interface MapLayerToolProps {
|
||||
visibilityState: VisibilityState;
|
||||
setVisibilityState: (state: VisibilityState) => void;
|
||||
heatmapMode: HeatmapMode;
|
||||
setHeatmapMode: (mode: HeatmapMode) => void;
|
||||
}
|
||||
|
||||
interface CheckboxProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CheckboxItem = ({ label, checked, onChange, className }: CheckboxProps) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
>
|
||||
<span className="dark:text-slate-200">{label}</span>
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
export function MapLayerTool({
|
||||
visibilityState,
|
||||
setVisibilityState,
|
||||
heatmapMode,
|
||||
setHeatmapMode,
|
||||
}: MapLayerToolProps): ReactNode {
|
||||
const { t } = useTranslation("map");
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
return Object.values(visibilityState).filter(Boolean).length;
|
||||
}, [visibilityState]);
|
||||
|
||||
const handleCheckboxChange = (key: keyof VisibilityState) => {
|
||||
if (key === "heatmap" && !visibilityState.heatmap) {
|
||||
// If turning heatmap on, turn everything else off so the layer is visible
|
||||
setVisibilityState({
|
||||
nodeMarkers: false,
|
||||
directNeighbors: false,
|
||||
remoteNeighbors: false,
|
||||
positionPrecision: false,
|
||||
traceroutes: false,
|
||||
waypoints: false,
|
||||
heatmap: true,
|
||||
});
|
||||
} else {
|
||||
setVisibilityState({
|
||||
...visibilityState,
|
||||
[key]: !visibilityState[key],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
{ key: "nodeMarkers", label: t("layerTool.nodeMarkers") },
|
||||
{ key: "waypoints", label: t("layerTool.waypoints") },
|
||||
{ key: "directNeighbors", label: t("layerTool.directNeighbors") },
|
||||
{ key: "remoteNeighbors", label: t("layerTool.remoteNeighbors") },
|
||||
{ key: "positionPrecision", label: t("layerTool.positionPrecision") },
|
||||
{ key: "heatmap", label: t("layerTool.heatmap") },
|
||||
// { key: "traceroutes", label: t("layerTool.traceroutes") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative rounded align-center",
|
||||
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20",
|
||||
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ",
|
||||
"text-slate-600 hover:text-slate-700 active:bg-slate-300",
|
||||
"dark:text-slate-600 hover:dark:text-slate-700",
|
||||
)}
|
||||
aria-label={t("mapMenu.layersAria")}
|
||||
>
|
||||
<LayersIcon className="w-[21px]" />
|
||||
{enabledCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-2 -right-2",
|
||||
"min-w-4 h-4 px-[3px]",
|
||||
"rounded-full text-[10px] leading-4",
|
||||
"bg-blue-500 text-white",
|
||||
"flex items-center justify-center",
|
||||
"ring-2 ring-white dark:ring-stone-200",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{enabledCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("dark:text-slate-300 flex flex-col space-y-2 py-4")}
|
||||
side={"bottom"}
|
||||
align="end"
|
||||
sideOffset={7}
|
||||
>
|
||||
{layers.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<CheckboxItem
|
||||
label={label}
|
||||
checked={visibilityState[key as keyof VisibilityState]}
|
||||
onChange={() => handleCheckboxChange(key as keyof VisibilityState)}
|
||||
/>
|
||||
{key === "heatmap" && visibilityState.heatmap && (
|
||||
<div className="pl-6 pt-2 flex flex-col gap-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="accent-blue-500"
|
||||
checked={heatmapMode === "density"}
|
||||
onChange={() => setHeatmapMode("density")}
|
||||
/>
|
||||
<span className="text-sm dark:text-slate-300">{t("layerTool.density")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="accent-blue-500"
|
||||
checked={heatmapMode === "snr"}
|
||||
onChange={() => setHeatmapMode("snr")}
|
||||
/>
|
||||
<span className="text-sm dark:text-slate-300">SNR</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/*<CheckboxItem
|
||||
key="traceroutes"
|
||||
label={t("layerTool.traceroutes")}
|
||||
checked={visibilityState.traceroutes}
|
||||
onChange={(checked) => {
|
||||
setVisibilityState({ ...visibilityState, traceroutes: checked });
|
||||
}}
|
||||
/>*/}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
export type ClusterKey = string;
|
||||
export type PxOffset = [number, number];
|
||||
|
||||
export function makeClusterKey(pos: Protobuf.Mesh.Position): ClusterKey {
|
||||
return `${pos.latitudeI},${pos.longitudeI}`;
|
||||
}
|
||||
|
||||
export function groupNodesByIdenticalCoords(
|
||||
nodes: Protobuf.Mesh.NodeInfo[],
|
||||
): Map<ClusterKey, Protobuf.Mesh.NodeInfo[]> {
|
||||
const map = new Map<ClusterKey, Protobuf.Mesh.NodeInfo[]>();
|
||||
for (const node of nodes) {
|
||||
if (!node.position) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = makeClusterKey(node.position);
|
||||
const arr = map.get(key);
|
||||
if (arr) {
|
||||
arr.push(node);
|
||||
} else {
|
||||
map.set(key, [node]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function hashToAngle(key: string): number {
|
||||
// djb2
|
||||
let h = 5381;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h = (h << 5) + h + key.charCodeAt(i);
|
||||
}
|
||||
// Map to [0, 2π)
|
||||
return ((h >>> 0) % 360) * (Math.PI / 180);
|
||||
}
|
||||
|
||||
export function fanOutOffsetsPx(size: number, key: string): Array<PxOffset> {
|
||||
const R = 10 + 5 * size; // radius in pixels
|
||||
const base = hashToAngle(key);
|
||||
const out: Array<PxOffset> = [];
|
||||
|
||||
if (size === 1) {
|
||||
return [[0, 0]];
|
||||
}
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const theta = base + (i * 2 * Math.PI) / size;
|
||||
const dx = R * Math.cos(theta);
|
||||
const dy = R * Math.sin(theta);
|
||||
out.push([dx, dy]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import { Separator } from "@components/UI/Separator";
|
||||
import { Skeleton } from "@components/UI/Skeleton.tsx";
|
||||
import type { Message } from "@core/stores/messageStore/types.ts";
|
||||
import type { TFunction } from "i18next";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { Fragment, Suspense, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
function toTs(d: Message["date"]): number {
|
||||
return typeof d === "number" ? d : Date.parse(String(d));
|
||||
}
|
||||
|
||||
function startOfLocalDay(ts: number): number {
|
||||
const d = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function formatDateLabelFromDayKey(
|
||||
dayKey: number,
|
||||
t: TFunction<"common", undefined>,
|
||||
fmt: Intl.DateTimeFormat,
|
||||
): string {
|
||||
const todayKey = startOfLocalDay(Date.now());
|
||||
const yestKey = todayKey - 24 * 60 * 60 * 1000;
|
||||
|
||||
if (dayKey === todayKey) {
|
||||
return t("unit.day.today"); // "Today" from common.json
|
||||
}
|
||||
if (dayKey === yestKey) {
|
||||
return t("unit.day.yesterday"); // "Yesterday" from common.json
|
||||
}
|
||||
return fmt.format(new Date(dayKey));
|
||||
}
|
||||
|
||||
type DayGroup = { dayKey: number; label: string; items: Message[] };
|
||||
|
||||
function groupMessagesByDay(
|
||||
messages: Message[],
|
||||
t: TFunction<"common", undefined>,
|
||||
fmt: Intl.DateTimeFormat,
|
||||
): DayGroup[] {
|
||||
const out: DayGroup[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const ts = toTs(msg.date);
|
||||
const dayKey = startOfLocalDay(ts);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.dayKey === dayKey) {
|
||||
last.items.push(msg);
|
||||
} else {
|
||||
out.push({
|
||||
dayKey,
|
||||
label: formatDateLabelFromDayKey(dayKey, t, fmt),
|
||||
items: [msg],
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DateDelimiter = ({ label }: { label: string }) => (
|
||||
<li aria-label={label}>
|
||||
<div className="my-2 flex h-3 items-center justify-center">
|
||||
<Separator className="bg-slate-100 dark:bg-slate-800" />
|
||||
<div className="mx-5 whitespace-nowrap text-center text-xs text-slate-400">{label}</div>
|
||||
<Separator className="bg-slate-100 dark:bg-slate-800" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
const MessageSkeleton = () => {
|
||||
console.log("[ChannelChat] Showing MessageSkeleton (Suspense fallback)");
|
||||
return (
|
||||
<li className="group w-full py-2 relative list-none rounded-md">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = () => {
|
||||
const { t } = useTranslation("messages");
|
||||
return (
|
||||
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
|
||||
<InboxIcon className="mb-2 h-8 w-8" />
|
||||
<span className="text-sm">{t("emptyState.text")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const locale = useMemo(
|
||||
() => i18n.language || (typeof navigator !== "undefined" ? navigator.language : "en-US"),
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
const dayLabelFmt = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
// Sort messages by date in case they are stored out of order
|
||||
const sorted = useMemo(
|
||||
() => [...messages].sort((a, b) => toTs(b.date) - toTs(a.date)),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupMessagesByDay(sorted, t, dayLabelFmt),
|
||||
[sorted, dayLabelFmt, t],
|
||||
);
|
||||
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<EmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col-reverse flex-grow overflow-y-auto px-3 py-2">
|
||||
{groups.map(({ dayKey, label, items }) => (
|
||||
<Fragment key={dayKey}>
|
||||
{/* Render messages first, then delimiter — with flex-col-reverse this shows the delimiter above that day's messages */}
|
||||
{items.map((message) => (
|
||||
<Suspense
|
||||
key={message.messageId ?? `${message.from}-${message.date}`}
|
||||
fallback={<MessageSkeleton />}
|
||||
>
|
||||
<MessageItem message={message} />
|
||||
</Suspense>
|
||||
))}
|
||||
<DateDelimiter label={label} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Reply, SmilePlus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MessageActionsMenuProps {
|
||||
onAddReaction?: () => void;
|
||||
onReply?: () => void;
|
||||
}
|
||||
|
||||
export const MessageActionsMenu = ({ onAddReaction, onReply }: MessageActionsMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hoverIconBarClass = cn(
|
||||
"absolute top-2 right-2",
|
||||
"flex items-center gap-x-1",
|
||||
"bg-white dark:bg-zinc-800",
|
||||
"border border-gray-200 dark:border-zinc-600",
|
||||
"rounded-md shadow-sm p-1",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"transition-opacity duration-100 ease-in-out",
|
||||
"z-10",
|
||||
);
|
||||
|
||||
const hoverIconButtonClass = cn(
|
||||
"p-1 rounded",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-gray-300",
|
||||
"hover:bg-gray-100 dark:hover:bg-zinc-700",
|
||||
"cursor-pointer",
|
||||
);
|
||||
|
||||
const iconSizeClass = "size-4";
|
||||
|
||||
return (
|
||||
<div className={cn(hoverIconBarClass)}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("messages_actionsMenu_addReactionLabel")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onAddReaction) {
|
||||
onAddReaction();
|
||||
}
|
||||
}}
|
||||
className={hoverIconButtonClass}
|
||||
>
|
||||
<SmilePlus className={iconSizeClass} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
||||
{t("messages_actionsMenu_addReactionLabel")}
|
||||
<TooltipArrow className="fill-gray-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("messages_actionsMenu_replyLabel")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onReply) {
|
||||
onReply();
|
||||
}
|
||||
}}
|
||||
className={hoverIconButtonClass}
|
||||
>
|
||||
<Reply className={iconSizeClass} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
||||
{t("messages_actionsMenu_replyLabel")}
|
||||
<TooltipArrow className="fill-gray-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit, ...rest }) => (
|
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", () => ({
|
||||
Input: vi.fn(({ minLength, name, placeholder, value, onChange }) => (
|
||||
<input
|
||||
minLength={minLength}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="message-input-field"
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useMessages: vi.fn(() => ({
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
})),
|
||||
MessageState: {
|
||||
Ack: "ack",
|
||||
Waiting: "waiting",
|
||||
Failed: "failed",
|
||||
},
|
||||
MessageType: {
|
||||
Direct: "direct",
|
||||
Broadcast: "broadcast",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||
}));
|
||||
|
||||
describe("MessageInput", () => {
|
||||
const mockOnSend = vi.fn();
|
||||
const defaultProps: MessageInputProps = {
|
||||
onSend: mockOnSend,
|
||||
to: 123,
|
||||
maxBytes: 256,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetDraft.mockReturnValue("");
|
||||
});
|
||||
|
||||
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
|
||||
render(<MessageInput {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
it("should render the input field, byte counter, and send button", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByTestId("message-input-field")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should initialize with the draft from the store", () => {
|
||||
const initialDraft = "Existing draft message";
|
||||
mockGetDraft.mockImplementation((key) => {
|
||||
return key === defaultProps.to ? initialDraft : "";
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
const expectedBytes = new Blob([initialDraft]).size;
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${expectedBytes}/${defaultProps.maxBytes}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update input value, byte counter, and call setDraft on change within limits", () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const testMessage = "Hello there!";
|
||||
const expectedBytes = new Blob([testMessage]).size;
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } });
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${expectedBytes}/${defaultProps.maxBytes}`,
|
||||
);
|
||||
expect(mockSetDraft).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
|
||||
});
|
||||
|
||||
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
|
||||
const smallMaxBytes = 5;
|
||||
renderComponent({ maxBytes: smallMaxBytes });
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const initialValue = "12345";
|
||||
const excessiveValue = "123456";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: initialValue } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
|
||||
mockSetDraft.mockClear();
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: excessiveValue } });
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
|
||||
`${smallMaxBytes}/${smallMaxBytes}`,
|
||||
);
|
||||
expect(mockSetDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const formElement = screen.getByRole("form");
|
||||
const testMessage = "Send this message";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } });
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
|
||||
expect((inputElement as HTMLInputElement).value).toBe("");
|
||||
expect(screen.getByTestId("byte-counter")).toHaveTextContent(`0/${defaultProps.maxBytes}`);
|
||||
expect(mockClearDraft).toHaveBeenCalledTimes(1);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
});
|
||||
});
|
||||
|
||||
it("should trim whitespace before calling onSend", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const formElement = screen.getByRole("form");
|
||||
const testMessageWithWhitespace = " Trim me! ";
|
||||
const expectedTrimmedMessage = "Trim me!";
|
||||
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: testMessageWithWhitespace },
|
||||
});
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call onSend or clearDraft if input is empty on submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const formElement = screen.getByRole("form");
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe("");
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
expect(mockClearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId("message-input-field");
|
||||
const formElement = screen.getByRole("form");
|
||||
const whitespaceMessage = " \t ";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
expect(mockClearDraft).not.toHaveBeenCalled();
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
|
||||
});
|
||||
|
||||
it("should work with broadcast destination for drafts", () => {
|
||||
const broadcastDest: Types.Destination = "broadcast";
|
||||
mockGetDraft.mockImplementation((key) => (key === broadcastDest ? "Broadcast draft" : ""));
|
||||
|
||||
renderComponent({ to: broadcastDest });
|
||||
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
expect((screen.getByTestId("message-input-field") as HTMLInputElement).value).toBe(
|
||||
"Broadcast draft",
|
||||
);
|
||||
|
||||
const inputElement = screen.getByTestId("message-input-field") as HTMLInputElement;
|
||||
const formElement = screen.getByRole("form");
|
||||
const newMessage = "New broadcast msg";
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: newMessage } });
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useMessages } from "@core/stores";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { startTransition, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface MessageInputProps {
|
||||
onSend: (message: string) => void;
|
||||
to: Types.Destination;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
|
||||
const { setDraft, getDraft, clearDraft } = useMessages();
|
||||
const { t } = useTranslation("messages");
|
||||
|
||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||
|
||||
const initialDraft = getDraft(to);
|
||||
const [localDraft, setLocalDraft] = useState(initialDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const byteLength = calculateBytes(newValue);
|
||||
|
||||
if (byteLength <= maxBytes) {
|
||||
setLocalDraft(newValue);
|
||||
setMessageBytes(byteLength);
|
||||
setDraft(to, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!localDraft.trim()) {
|
||||
return;
|
||||
}
|
||||
// Reset bytes *before* sending (consider if onSend failure needs different handling)
|
||||
setMessageBytes(0);
|
||||
|
||||
startTransition(() => {
|
||||
onSend(localDraft.trim());
|
||||
setLocalDraft("");
|
||||
clearDraft(to);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-1">
|
||||
<label className="w-full" htmlFor="messageInput">
|
||||
<Input
|
||||
minLength={1}
|
||||
name="messageInput"
|
||||
placeholder={t("sendMessage.placeholder")}
|
||||
autoComplete="off"
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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">
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { MessageState, useAppStore, useDevice, useNodeDB } from "@core/stores";
|
||||
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";
|
||||
|
||||
// Cache for pending promises
|
||||
const myNodePromises = new Map<string, Promise<Protobuf.Mesh.NodeInfo>>();
|
||||
|
||||
// Hook that suspends when myNode is not available
|
||||
function useSuspendingMyNode() {
|
||||
const { getMyNode } = useNodeDB();
|
||||
const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
|
||||
const myNode = getMyNode();
|
||||
|
||||
if (!myNode) {
|
||||
// Use the selected device ID to cache promises per device
|
||||
const deviceKey = `device-${selectedDeviceId}`;
|
||||
|
||||
if (!myNodePromises.has(deviceKey)) {
|
||||
const promise = new Promise<Protobuf.Mesh.NodeInfo>((resolve) => {
|
||||
// Poll for myNode to become available
|
||||
const checkInterval = setInterval(() => {
|
||||
const node = getMyNode();
|
||||
if (node) {
|
||||
console.log("[MessageItem] myNode now available, resolving promise");
|
||||
clearInterval(checkInterval);
|
||||
myNodePromises.delete(deviceKey);
|
||||
resolve(node);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
myNodePromises.delete(deviceKey);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
myNodePromises.set(deviceKey, promise);
|
||||
}
|
||||
|
||||
// Throw the promise to trigger Suspense
|
||||
throw myNodePromises.get(deviceKey);
|
||||
}
|
||||
|
||||
return myNode;
|
||||
}
|
||||
|
||||
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
|
||||
|
||||
interface MessageStatusInfo {
|
||||
displayText: string;
|
||||
icon: LucideIcon;
|
||||
ariaLabel: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
const StatusTooltip = ({
|
||||
statusInfo,
|
||||
children,
|
||||
}: {
|
||||
statusInfo: MessageStatusInfo;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{statusInfo.displayText}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const { config } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const { t, i18n } = useTranslation("messages");
|
||||
|
||||
// This will suspend if myNode is not available yet
|
||||
const myNode = useSuspendingMyNode();
|
||||
const myNodeNum = myNode.num;
|
||||
|
||||
const MESSAGE_STATUS_MAP = useMemo(
|
||||
(): Record<MessageState, MessageStatusInfo> => ({
|
||||
[MessageState.Ack]: {
|
||||
displayText: t("deliveryStatus.delivered.displayText"),
|
||||
icon: CheckCircle2,
|
||||
ariaLabel: t("deliveryStatus.delivered.label"),
|
||||
iconClassName: "text-green-500",
|
||||
},
|
||||
[MessageState.Waiting]: {
|
||||
displayText: t("deliveryStatus.waiting.displayText"),
|
||||
icon: CircleEllipsis,
|
||||
ariaLabel: t("deliveryStatus.waiting.label"),
|
||||
iconClassName: "text-slate-400",
|
||||
},
|
||||
[MessageState.Failed]: {
|
||||
displayText: t("deliveryStatus.failed.displayText"),
|
||||
icon: AlertCircle,
|
||||
ariaLabel: t("deliveryStatus.failed.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,
|
||||
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
|
||||
);
|
||||
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
||||
return message.from != null ? getNode(message.from) : null;
|
||||
}, [getNode, message.from]);
|
||||
|
||||
const { displayName, isFavorite, nodeNum } = useMemo(() => {
|
||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const fallbackName = t("fallbackName", { last4 });
|
||||
const longName = messageUser?.user?.longName;
|
||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||
const derivedDisplayName = longName || derivedShortName;
|
||||
const isFavorite = messageUser?.num !== myNodeNum && messageUser?.isFavorite;
|
||||
return {
|
||||
displayName: derivedDisplayName,
|
||||
shortName: derivedShortName,
|
||||
isFavorite: isFavorite,
|
||||
nodeNum: message.from,
|
||||
};
|
||||
}, [messageUser, message.from, t, myNodeNum]);
|
||||
|
||||
const messageStatusInfo = getMessageStatusInfo(message.state);
|
||||
const StatusIconComponent = messageStatusInfo.icon;
|
||||
|
||||
const messageDate = useMemo(() => (message.date ? new Date(message.date) : null), [message.date]);
|
||||
const locale = i18n.language;
|
||||
|
||||
const formattedTime = useMemo(
|
||||
() =>
|
||||
messageDate?.toLocaleTimeString(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: config?.display?.use12hClock ?? true,
|
||||
}) ?? "",
|
||||
[messageDate, locale, config?.display?.use12hClock],
|
||||
);
|
||||
|
||||
const fullDateTime = useMemo(
|
||||
() =>
|
||||
messageDate?.toLocaleString(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}) ?? "",
|
||||
[messageDate, locale],
|
||||
);
|
||||
|
||||
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
|
||||
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
|
||||
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
|
||||
|
||||
const messageItemWrapperClass = cn(
|
||||
"group w-full py-2 relative list-none",
|
||||
"rounded-md",
|
||||
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
|
||||
"transition-colors duration-100 ease-in-out",
|
||||
);
|
||||
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
|
||||
|
||||
return (
|
||||
<li className={messageItemWrapperClass}>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<Avatar size="sm" nodeNum={nodeNum} className="pt-0.5" showFavorite={isFavorite} />
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
|
||||
{displayName}
|
||||
</span>
|
||||
{messageDate && (
|
||||
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
|
||||
<span aria-hidden="true">{formattedTime}</span>
|
||||
<span className="sr-only">{fullDateTime}</span>
|
||||
</time>
|
||||
)}
|
||||
{shouldShowStatusIcon && (
|
||||
<StatusTooltip statusInfo={messageStatusInfo}>
|
||||
<span aria-label={messageStatusInfo.ariaLabel} role="img">
|
||||
<StatusIconComponent
|
||||
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</StatusTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message?.message && (
|
||||
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
|
||||
{message.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions Menu Placeholder */}
|
||||
{/* <div className="absolute top-1 right-1">
|
||||
<MessageActionsMenu onReply={() => console.log("Reply")} />
|
||||
</div> */}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.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");
|
||||
|
||||
describe("TraceRoute", () => {
|
||||
const fromUser = {
|
||||
user: {
|
||||
$typeName: "meshtastic.User",
|
||||
longName: "Source Node",
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
shortName: "Source",
|
||||
hwModel: 1,
|
||||
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
|
||||
id: "source-node",
|
||||
isLicensed: false,
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
} as Protobuf.Mesh.NodeInfo["user"],
|
||||
};
|
||||
|
||||
const toUser = {
|
||||
user: {
|
||||
$typeName: "meshtastic.User",
|
||||
longName: "Destination Node",
|
||||
publicKey: new Uint8Array([4, 5, 6]),
|
||||
shortName: "Destination",
|
||||
hwModel: 2,
|
||||
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
|
||||
id: "destination-node",
|
||||
isLicensed: false,
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
} as Protobuf.Mesh.NodeInfo["user"],
|
||||
};
|
||||
|
||||
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
|
||||
[
|
||||
1,
|
||||
{
|
||||
num: 1,
|
||||
user: { longName: "Node A", $typeName: "meshtastic.User" },
|
||||
$typeName: "meshtastic.NodeInfo",
|
||||
} as Protobuf.Mesh.NodeInfo,
|
||||
],
|
||||
[
|
||||
2,
|
||||
{
|
||||
num: 2,
|
||||
user: { longName: "Node B", $typeName: "meshtastic.User" },
|
||||
$typeName: "meshtastic.NodeInfo",
|
||||
} as Protobuf.Mesh.NodeInfo,
|
||||
],
|
||||
[
|
||||
3,
|
||||
{
|
||||
num: 3,
|
||||
user: { longName: "Node C", $typeName: "meshtastic.User" },
|
||||
$typeName: "meshtastic.NodeInfo",
|
||||
} as Protobuf.Mesh.NodeInfo,
|
||||
],
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(useNodeDB).mockReturnValue({
|
||||
...mockNodeDBStore,
|
||||
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
|
||||
return mockNodes.get(nodeNum);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the route to destination with SNR values", () => {
|
||||
render(<TraceRoute from={fromUser} to={toUser} route={[1, 2]} snrTowards={[10, 20, 30]} />);
|
||||
|
||||
expect(screen.getByText("Source Node")).toBeInTheDocument();
|
||||
expect(screen.getByText("Destination Node")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node B")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText(/↓/)).toHaveLength(3);
|
||||
expect(screen.getByText("↓ 10dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 20dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 30dBm")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the route back when provided", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={fromUser}
|
||||
to={toUser}
|
||||
route={[1]}
|
||||
snrTowards={[15, 25]}
|
||||
routeBack={[3]}
|
||||
snrBack={[35, 45]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for the translated title
|
||||
expect(screen.getByText("Route back:")).toBeInTheDocument();
|
||||
|
||||
// With route back, both names appear twice
|
||||
expect(screen.getAllByText("Source Node")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
|
||||
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node C")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 25dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 35dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 45dBm")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders '??' for missing SNR values", () => {
|
||||
render(<TraceRoute from={fromUser} to={toUser} route={[1]} />);
|
||||
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
// Check for translated '??' placeholder
|
||||
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type NodeUser = Pick<Protobuf.Mesh.NodeInfo, "user">;
|
||||
|
||||
export interface TraceRouteProps {
|
||||
from: NodeUser;
|
||||
to: NodeUser;
|
||||
route: Array<number>;
|
||||
routeBack?: Array<number>;
|
||||
snrTowards?: Array<number>;
|
||||
snrBack?: Array<number>;
|
||||
}
|
||||
|
||||
interface RoutePathProps {
|
||||
title: string;
|
||||
from: NodeUser;
|
||||
to: NodeUser;
|
||||
path: number[];
|
||||
snr?: number[];
|
||||
}
|
||||
|
||||
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
|
||||
const { getNode } = useNodeDB();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
id={title}
|
||||
className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100"
|
||||
>
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p>{from?.user?.longName}</p>
|
||||
<p>
|
||||
↓ {snr?.[0] ?? t("unknown.num")}
|
||||
{t("unit.dbm")}
|
||||
</p>
|
||||
{path.map((hop, i) => (
|
||||
<span key={getNode(hop)?.num ?? hop}>
|
||||
<p>
|
||||
{getNode(hop)?.user?.longName ??
|
||||
`${t("unknown.longName")} (!${numberToHexUnpadded(hop)})`}
|
||||
</p>
|
||||
<p>
|
||||
↓ {snr?.[i + 1] ?? t("unknown.num")}
|
||||
{t("unit.dbm")}
|
||||
</p>
|
||||
</span>
|
||||
))}
|
||||
<p>{to?.user?.longName}</p>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const TraceRoute = ({
|
||||
from,
|
||||
to,
|
||||
route,
|
||||
routeBack,
|
||||
snrTowards,
|
||||
snrBack,
|
||||
}: TraceRouteProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
return (
|
||||
<div className="ml-5 flex">
|
||||
<RoutePath
|
||||
title={t("traceRoute.routeToDestination")}
|
||||
to={to}
|
||||
from={from}
|
||||
path={route}
|
||||
snr={snrTowards}
|
||||
/>
|
||||
{routeBack && routeBack.length > 0 && (
|
||||
<RoutePath
|
||||
title={t("traceRoute.routeBack")}
|
||||
to={from}
|
||||
from={to}
|
||||
path={routeBack}
|
||||
snr={snrBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type AmbientLightingValidation,
|
||||
AmbientLightingValidationSchema,
|
||||
} from "@app/validation/moduleConfig/ambientLighting.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AmbientLightingModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
|
||||
}
|
||||
|
||||
export const AmbientLighting = ({ onFormInit }: AmbientLightingModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: AmbientLightingValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "ambientLighting" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange(
|
||||
{ type: "moduleConfig", variant: "ambientLighting" },
|
||||
data,
|
||||
moduleConfig.ambientLighting,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<AmbientLightingValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={AmbientLightingValidationSchema}
|
||||
defaultValues={moduleConfig.ambientLighting}
|
||||
values={getEffectiveModuleConfig("ambientLighting")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("ambientLighting.title"),
|
||||
description: t("ambientLighting.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "ledState",
|
||||
label: t("ambientLighting.ledState.label"),
|
||||
description: t("ambientLighting.ledState.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "current",
|
||||
label: t("ambientLighting.current.label"),
|
||||
description: t("ambientLighting.current.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "red",
|
||||
label: t("ambientLighting.red.label"),
|
||||
description: t("ambientLighting.red.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "green",
|
||||
label: t("ambientLighting.green.label"),
|
||||
description: t("ambientLighting.green.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "blue",
|
||||
label: t("ambientLighting.blue.label"),
|
||||
description: t("ambientLighting.blue.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type AudioValidation, AudioValidationSchema } from "@app/validation/moduleConfig/audio.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AudioModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AudioValidation>;
|
||||
}
|
||||
|
||||
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "audio" });
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: AudioValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.audio, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "audio" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "audio" }, data, moduleConfig.audio);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<AudioValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={AudioValidationSchema}
|
||||
defaultValues={moduleConfig.audio}
|
||||
values={getEffectiveModuleConfig("audio")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("audio.title"),
|
||||
description: t("audio.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "codec2Enabled",
|
||||
label: t("audio.codec2Enabled.label"),
|
||||
description: t("audio.codec2Enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "pttPin",
|
||||
label: t("audio.pttPin.label"),
|
||||
description: t("audio.pttPin.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "bitrate",
|
||||
label: t("audio.bitrate.label"),
|
||||
description: t("audio.bitrate.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "i2sWs",
|
||||
label: t("audio.i2sWs.label"),
|
||||
description: t("audio.i2sWs.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "i2sSd",
|
||||
label: t("audio.i2sSd.label"),
|
||||
description: t("audio.i2sSd.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "i2sDin",
|
||||
label: t("audio.i2sDin.label"),
|
||||
description: t("audio.i2sDin.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "i2sSck",
|
||||
label: t("audio.i2sSck.label"),
|
||||
description: t("audio.i2sSck.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type CannedMessageValidation,
|
||||
CannedMessageValidationSchema,
|
||||
} from "@app/validation/moduleConfig/cannedMessage.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CannedMessageModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
|
||||
}
|
||||
|
||||
export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: CannedMessageValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "cannedMessage" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "cannedMessage" }, data, moduleConfig.cannedMessage);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<CannedMessageValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={CannedMessageValidationSchema}
|
||||
defaultValues={moduleConfig.cannedMessage}
|
||||
values={getEffectiveModuleConfig("cannedMessage")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("cannedMessage.title"),
|
||||
description: t("cannedMessage.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("cannedMessage.moduleEnabled.label"),
|
||||
description: t("cannedMessage.moduleEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "rotary1Enabled",
|
||||
label: t("cannedMessage.rotary1Enabled.label"),
|
||||
description: t("cannedMessage.rotary1Enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "inputbrokerPinA",
|
||||
label: t("cannedMessage.inputbrokerPinA.label"),
|
||||
description: t("cannedMessage.inputbrokerPinA.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "inputbrokerPinB",
|
||||
label: t("cannedMessage.inputbrokerPinB.label"),
|
||||
description: t("cannedMessage.inputbrokerPinB.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "inputbrokerPinPress",
|
||||
label: t("cannedMessage.inputbrokerPinPress.label"),
|
||||
description: t("cannedMessage.inputbrokerPinPress.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "inputbrokerEventCw",
|
||||
label: t("cannedMessage.inputbrokerEventCw.label"),
|
||||
description: t("cannedMessage.inputbrokerEventCw.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "inputbrokerEventCcw",
|
||||
label: t("cannedMessage.inputbrokerEventCcw.label"),
|
||||
description: t("cannedMessage.inputbrokerEventCcw.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "inputbrokerEventPress",
|
||||
label: t("cannedMessage.inputbrokerEventPress.label"),
|
||||
description: t("cannedMessage.inputbrokerEventPress.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "updown1Enabled",
|
||||
label: t("cannedMessage.updown1Enabled.label"),
|
||||
description: t("cannedMessage.updown1Enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "allowInputSource",
|
||||
label: t("cannedMessage.allowInputSource.label"),
|
||||
description: t("cannedMessage.allowInputSource.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "sendBell",
|
||||
label: t("cannedMessage.sendBell.label"),
|
||||
description: t("cannedMessage.sendBell.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type DetectionSensorValidation,
|
||||
DetectionSensorValidationSchema,
|
||||
} from "@app/validation/moduleConfig/detectionSensor.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DetectionSensorModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
|
||||
}
|
||||
|
||||
export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: DetectionSensorValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "detectionSensor" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange(
|
||||
{ type: "moduleConfig", variant: "detectionSensor" },
|
||||
data,
|
||||
moduleConfig.detectionSensor,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DetectionSensorValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DetectionSensorValidationSchema}
|
||||
defaultValues={moduleConfig.detectionSensor}
|
||||
values={getEffectiveModuleConfig("detectionSensor")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("detectionSensor.title"),
|
||||
description: t("detectionSensor.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("detectionSensor.enabled.label"),
|
||||
description: t("detectionSensor.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "minimumBroadcastSecs",
|
||||
label: t("detectionSensor.minimumBroadcastSecs.label"),
|
||||
description: t("detectionSensor.minimumBroadcastSecs.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "stateBroadcastSecs",
|
||||
label: t("detectionSensor.stateBroadcastSecs.label"),
|
||||
description: t("detectionSensor.stateBroadcastSecs.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "sendBell",
|
||||
label: t("detectionSensor.sendBell.label"),
|
||||
description: t("detectionSensor.sendBell.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
label: t("detectionSensor.name.label"),
|
||||
description: t("detectionSensor.name.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "monitorPin",
|
||||
label: t("detectionSensor.monitorPin.label"),
|
||||
description: t("detectionSensor.monitorPin.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "detectionTriggerType",
|
||||
label: t("detectionSensor.detectionTriggerType.label"),
|
||||
description: t("detectionSensor.detectionTriggerType.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "usePullup",
|
||||
label: t("detectionSensor.usePullup.label"),
|
||||
description: t("detectionSensor.usePullup.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type ExternalNotificationValidation,
|
||||
ExternalNotificationValidationSchema,
|
||||
} from "@app/validation/moduleConfig/externalNotification.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ExternalNotificationModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
|
||||
}
|
||||
|
||||
export const ExternalNotification = ({ onFormInit }: ExternalNotificationModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "externalNotification" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: ExternalNotificationValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "externalNotification" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange(
|
||||
{ type: "moduleConfig", variant: "externalNotification" },
|
||||
data,
|
||||
moduleConfig.externalNotification,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<ExternalNotificationValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={ExternalNotificationValidationSchema}
|
||||
defaultValues={moduleConfig.externalNotification}
|
||||
values={getEffectiveModuleConfig("externalNotification")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("externalNotification.title"),
|
||||
description: t("externalNotification.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("externalNotification.enabled.label"),
|
||||
description: t("externalNotification.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "outputMs",
|
||||
label: t("externalNotification.outputMs.label"),
|
||||
description: t("externalNotification.outputMs.description"),
|
||||
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
suffix: t("unit.millisecond.suffix"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "output",
|
||||
label: t("externalNotification.output.label"),
|
||||
description: t("externalNotification.output.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "outputVibra",
|
||||
label: t("externalNotification.outputVibra.label"),
|
||||
description: t("externalNotification.outputVibra.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "outputBuzzer",
|
||||
label: t("externalNotification.outputBuzzer.label"),
|
||||
description: t("externalNotification.outputBuzzer.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "active",
|
||||
label: t("externalNotification.active.label"),
|
||||
description: t("externalNotification.active.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertMessage",
|
||||
label: t("externalNotification.alertMessage.label"),
|
||||
description: t("externalNotification.alertMessage.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertMessageVibra",
|
||||
label: t("externalNotification.alertMessageVibra.label"),
|
||||
description: t("externalNotification.alertMessageVibra.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertMessageBuzzer",
|
||||
label: t("externalNotification.alertMessageBuzzer.label"),
|
||||
description: t("externalNotification.alertMessageBuzzer.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertBell",
|
||||
label: t("externalNotification.alertBell.label"),
|
||||
description: t("externalNotification.alertBell.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertBellVibra",
|
||||
label: t("externalNotification.alertBellVibra.label"),
|
||||
description: t("externalNotification.alertBellVibra.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "alertBellBuzzer",
|
||||
label: t("externalNotification.alertBellBuzzer.label"),
|
||||
description: t("externalNotification.alertBellBuzzer.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "usePwm",
|
||||
label: t("externalNotification.usePwm.label"),
|
||||
description: t("externalNotification.usePwm.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "nagTimeout",
|
||||
label: t("externalNotification.nagTimeout.label"),
|
||||
description: t("externalNotification.nagTimeout.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "useI2sAsBuzzer",
|
||||
label: t("externalNotification.useI2sAsBuzzer.label"),
|
||||
description: t("externalNotification.useI2sAsBuzzer.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type MqttValidation, MqttValidationSchema } from "@app/validation/moduleConfig/mqtt.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MqttModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<MqttValidation>;
|
||||
}
|
||||
|
||||
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "mqtt" });
|
||||
|
||||
const { config, moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: MqttValidation) => {
|
||||
const payload = {
|
||||
...data,
|
||||
mapReportSettings: create(
|
||||
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
||||
data.mapReportSettings,
|
||||
),
|
||||
};
|
||||
|
||||
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "mqtt" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "mqtt" }, payload, moduleConfig.mqtt);
|
||||
};
|
||||
|
||||
const populateDefaultValues = (
|
||||
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
|
||||
) => {
|
||||
return cfg
|
||||
? {
|
||||
...cfg,
|
||||
mapReportSettings: cfg.mapReportSettings ?? {
|
||||
publishIntervalSecs: 0,
|
||||
positionPrecision: 10,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<MqttValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={MqttValidationSchema}
|
||||
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
||||
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("mqtt.title"),
|
||||
description: t("mqtt.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("mqtt.enabled.label"),
|
||||
description: t("mqtt.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "address",
|
||||
label: t("mqtt.address.label"),
|
||||
description: t("mqtt.address.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "username",
|
||||
label: t("mqtt.username.label"),
|
||||
description: t("mqtt.username.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
name: "password",
|
||||
label: t("mqtt.password.label"),
|
||||
description: t("mqtt.password.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "encryptionEnabled",
|
||||
label: t("mqtt.encryptionEnabled.label"),
|
||||
description: t("mqtt.encryptionEnabled.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "jsonEnabled",
|
||||
label: t("mqtt.jsonEnabled.label"),
|
||||
description: t("mqtt.jsonEnabled.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "tlsEnabled",
|
||||
label: t("mqtt.tlsEnabled.label"),
|
||||
description: t("mqtt.tlsEnabled.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "root",
|
||||
label: t("mqtt.root.label"),
|
||||
description: t("mqtt.root.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "proxyToClientEnabled",
|
||||
label: t("mqtt.proxyToClientEnabled.label"),
|
||||
description: t("mqtt.proxyToClientEnabled.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "mapReportingEnabled",
|
||||
label: t("mqtt.mapReportingEnabled.label"),
|
||||
description: t("mqtt.mapReportingEnabled.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "mapReportSettings.publishIntervalSecs",
|
||||
label: t("mqtt.mapReportSettings.publishIntervalSecs.label"),
|
||||
description: t("mqtt.mapReportSettings.publishIntervalSecs.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
{
|
||||
fieldName: "mapReportingEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "mapReportSettings.positionPrecision",
|
||||
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,
|
||||
},
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
{
|
||||
fieldName: "mapReportingEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type NeighborInfoValidation,
|
||||
NeighborInfoValidationSchema,
|
||||
} from "@app/validation/moduleConfig/neighborInfo.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface NeighborInfoModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
|
||||
}
|
||||
|
||||
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: NeighborInfoValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "neighborInfo" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "neighborInfo" }, data, moduleConfig.neighborInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<NeighborInfoValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NeighborInfoValidationSchema}
|
||||
defaultValues={moduleConfig.neighborInfo}
|
||||
values={getEffectiveModuleConfig("neighborInfo")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("neighborInfo.title"),
|
||||
description: t("neighborInfo.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("neighborInfo.enabled.label"),
|
||||
description: t("neighborInfo.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "updateInterval",
|
||||
label: t("neighborInfo.updateInterval.label"),
|
||||
description: t("neighborInfo.updateInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type PaxcounterValidation,
|
||||
PaxcounterValidationSchema,
|
||||
} from "@app/validation/moduleConfig/paxcounter.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PaxcounterModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
|
||||
}
|
||||
|
||||
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "paxcounter" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: PaxcounterValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "paxcounter" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "paxcounter" }, data, moduleConfig.paxcounter);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<PaxcounterValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PaxcounterValidationSchema}
|
||||
defaultValues={moduleConfig.paxcounter}
|
||||
values={getEffectiveModuleConfig("paxcounter")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("paxcounter.title"),
|
||||
description: t("paxcounter.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("paxcounter.enabled.label"),
|
||||
description: t("paxcounter.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "paxcounterUpdateInterval",
|
||||
label: t("paxcounter.paxcounterUpdateInterval.label"),
|
||||
description: t("paxcounter.paxcounterUpdateInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "wifiThreshold",
|
||||
label: t("paxcounter.wifiThreshold.label"),
|
||||
description: t("paxcounter.wifiThreshold.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "bleThreshold",
|
||||
label: t("paxcounter.bleThreshold.label"),
|
||||
description: t("paxcounter.bleThreshold.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type RangeTestValidation,
|
||||
RangeTestValidationSchema,
|
||||
} from "@app/validation/moduleConfig/rangeTest.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface RangeTestModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RangeTestValidation>;
|
||||
}
|
||||
|
||||
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "rangeTest" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: RangeTestValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "rangeTest" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "rangeTest" }, data, moduleConfig.rangeTest);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<RangeTestValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={RangeTestValidationSchema}
|
||||
defaultValues={moduleConfig.rangeTest}
|
||||
values={getEffectiveModuleConfig("rangeTest")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("rangeTest.title"),
|
||||
description: t("rangeTest.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("rangeTest.enabled.label"),
|
||||
description: t("rangeTest.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "sender",
|
||||
label: t("rangeTest.sender.label"),
|
||||
description: t("rangeTest.sender.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "save",
|
||||
label: t("rangeTest.save.label"),
|
||||
description: t("rangeTest.save.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type RemoteHardwareValidation,
|
||||
RemoteHardwareValidationSchema,
|
||||
} from "@app/validation/moduleConfig/remoteHardware.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface RemoteHardwareModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RemoteHardwareValidation>;
|
||||
}
|
||||
|
||||
export const RemoteHardware = ({ onFormInit }: RemoteHardwareModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "remoteHardware" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: RemoteHardwareValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.remoteHardware, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "remoteHardware" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange(
|
||||
{ type: "moduleConfig", variant: "remoteHardware" },
|
||||
data,
|
||||
moduleConfig.remoteHardware,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<RemoteHardwareValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={RemoteHardwareValidationSchema}
|
||||
defaultValues={moduleConfig.remoteHardware}
|
||||
values={getEffectiveModuleConfig("remoteHardware")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("remoteHardware.title"),
|
||||
description: t("remoteHardware.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("remoteHardware.enabled.label"),
|
||||
description: t("remoteHardware.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "allowUndefinedPinAccess",
|
||||
label: t("remoteHardware.allowUndefinedPinAccess.label"),
|
||||
description: t("remoteHardware.allowUndefinedPinAccess.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type SerialValidation,
|
||||
SerialValidationSchema,
|
||||
} from "@app/validation/moduleConfig/serial.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SerialModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<SerialValidation>;
|
||||
}
|
||||
|
||||
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "serial" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: SerialValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.serial, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "serial" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "serial" }, data, moduleConfig.serial);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<SerialValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={SerialValidationSchema}
|
||||
defaultValues={moduleConfig.serial}
|
||||
values={getEffectiveModuleConfig("serial")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("serial.title"),
|
||||
description: t("serial.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("serial.enabled.label"),
|
||||
description: t("serial.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "echo",
|
||||
label: t("serial.echo.label"),
|
||||
description: t("serial.echo.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "rxd",
|
||||
label: t("serial.rxd.label"),
|
||||
description: t("serial.rxd.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "txd",
|
||||
label: t("serial.txd.label"),
|
||||
description: t("serial.txd.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "baud",
|
||||
label: t("serial.baud.label"),
|
||||
description: t("serial.baud.description"),
|
||||
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "timeout",
|
||||
label: t("serial.timeout.label"),
|
||||
description: t("serial.timeout.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "mode",
|
||||
label: t("serial.mode.label"),
|
||||
description: t("serial.mode.description"),
|
||||
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "overrideConsoleSerialPort",
|
||||
label: t("serial.overrideConsoleSerialPort.label"),
|
||||
description: t("serial.overrideConsoleSerialPort.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type StatusMessageValidation,
|
||||
StatusMessageValidationSchema,
|
||||
} from "@app/validation/moduleConfig/statusMessage.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StatusMessageModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<StatusMessageValidation>;
|
||||
}
|
||||
|
||||
export const StatusMessage = ({ onFormInit }: StatusMessageModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "statusmessage" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: StatusMessageValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.statusmessage, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "statusmessage" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "statusmessage" }, data, moduleConfig.statusmessage);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<StatusMessageValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={StatusMessageValidationSchema}
|
||||
defaultValues={moduleConfig.statusmessage}
|
||||
values={getEffectiveModuleConfig("statusmessage")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("statusMessage.title"),
|
||||
description: t("statusMessage.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
name: "nodeStatus",
|
||||
label: t("statusMessage.nodeStatus.label"),
|
||||
description: t("statusMessage.nodeStatus.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 200,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type StoreForwardValidation,
|
||||
StoreForwardValidationSchema,
|
||||
} from "@app/validation/moduleConfig/storeForward.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StoreForwardModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
|
||||
}
|
||||
|
||||
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "storeForward" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: StoreForwardValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "storeForward" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "storeForward" }, data, moduleConfig.storeForward);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<StoreForwardValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={StoreForwardValidationSchema}
|
||||
defaultValues={moduleConfig.storeForward}
|
||||
values={getEffectiveModuleConfig("storeForward")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("storeForward.title"),
|
||||
description: t("storeForward.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("storeForward.enabled.label"),
|
||||
description: t("storeForward.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "heartbeat",
|
||||
label: t("storeForward.heartbeat.label"),
|
||||
description: t("storeForward.heartbeat.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "records",
|
||||
label: t("storeForward.records.label"),
|
||||
description: t("storeForward.records.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
suffix: t("unit.record.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "historyReturnMax",
|
||||
label: t("storeForward.historyReturnMax.label"),
|
||||
description: t("storeForward.historyReturnMax.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "historyReturnWindow",
|
||||
label: t("storeForward.historyReturnWindow.label"),
|
||||
description: t("storeForward.historyReturnWindow.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type TakValidation, TakValidationSchema } from "@app/validation/moduleConfig/tak.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TakModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<TakValidation>;
|
||||
}
|
||||
|
||||
export const Tak = ({ onFormInit }: TakModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "tak" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: TakValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.tak, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "tak" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "tak" }, data, moduleConfig.tak);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<TakValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={TakValidationSchema}
|
||||
defaultValues={moduleConfig.tak}
|
||||
values={getEffectiveModuleConfig("tak")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("tak.title"),
|
||||
description: t("tak.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "team",
|
||||
label: t("tak.team.label"),
|
||||
description: t("tak.team.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ATAK.Team,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "role",
|
||||
label: t("tak.role.label"),
|
||||
description: t("tak.role.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.ATAK.MemberRole,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type TelemetryValidation,
|
||||
TelemetryValidationSchema,
|
||||
} from "@app/validation/moduleConfig/telemetry.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TelemetryModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<TelemetryValidation>;
|
||||
}
|
||||
|
||||
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "telemetry" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: TelemetryValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "telemetry" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "moduleConfig", variant: "telemetry" }, data, moduleConfig.telemetry);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<TelemetryValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={TelemetryValidationSchema}
|
||||
defaultValues={moduleConfig.telemetry}
|
||||
values={getEffectiveModuleConfig("telemetry")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("telemetry.title"),
|
||||
description: t("telemetry.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
name: "deviceUpdateInterval",
|
||||
label: t("telemetry.deviceUpdateInterval.label"),
|
||||
description: t("telemetry.deviceUpdateInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "environmentUpdateInterval",
|
||||
label: t("telemetry.environmentUpdateInterval.label"),
|
||||
description: t("telemetry.environmentUpdateInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "environmentMeasurementEnabled",
|
||||
label: t("telemetry.environmentMeasurementEnabled.label"),
|
||||
description: t("telemetry.environmentMeasurementEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "environmentScreenEnabled",
|
||||
label: t("telemetry.environmentScreenEnabled.label"),
|
||||
description: t("telemetry.environmentScreenEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "environmentDisplayFahrenheit",
|
||||
label: t("telemetry.environmentDisplayFahrenheit.label"),
|
||||
description: t("telemetry.environmentDisplayFahrenheit.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "airQualityEnabled",
|
||||
label: t("telemetry.airQualityEnabled.label"),
|
||||
description: t("telemetry.airQualityEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "airQualityInterval",
|
||||
label: t("telemetry.airQualityInterval.label"),
|
||||
description: t("telemetry.airQualityInterval.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "airQualityScreenEnabled",
|
||||
label: t("telemetry.airQualityScreenEnabled.label"),
|
||||
description: t("telemetry.airQualityScreenEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "powerMeasurementEnabled",
|
||||
label: t("telemetry.powerMeasurementEnabled.label"),
|
||||
description: t("telemetry.powerMeasurementEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "powerUpdateInterval",
|
||||
label: t("telemetry.powerUpdateInterval.label"),
|
||||
description: t("telemetry.powerUpdateInterval.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "powerScreenEnabled",
|
||||
label: t("telemetry.powerScreenEnabled.label"),
|
||||
description: t("telemetry.powerScreenEnabled.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type TrafficManagementValidation,
|
||||
TrafficManagementValidationSchema,
|
||||
} from "@app/validation/moduleConfig/trafficManagement.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TrafficManagementModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<TrafficManagementValidation>;
|
||||
}
|
||||
|
||||
export const TrafficManagement = ({ onFormInit }: TrafficManagementModuleConfigProps) => {
|
||||
useWaitForConfig({ moduleConfigCase: "trafficManagement" });
|
||||
|
||||
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: TrafficManagementValidation) => {
|
||||
if (deepCompareConfig(moduleConfig.trafficManagement, data, true)) {
|
||||
removeChange({ type: "moduleConfig", variant: "trafficManagement" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange(
|
||||
{ type: "moduleConfig", variant: "trafficManagement" },
|
||||
data,
|
||||
moduleConfig.trafficManagement,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<TrafficManagementValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={TrafficManagementValidationSchema}
|
||||
defaultValues={moduleConfig.trafficManagement}
|
||||
values={getEffectiveModuleConfig("trafficManagement")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("trafficManagement.title"),
|
||||
description: t("trafficManagement.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("trafficManagement.enabled.label"),
|
||||
description: t("trafficManagement.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "positionDedupEnabled",
|
||||
label: t("trafficManagement.positionDedupEnabled.label"),
|
||||
description: t("trafficManagement.positionDedupEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "positionPrecisionBits",
|
||||
label: t("trafficManagement.positionPrecisionBits.label"),
|
||||
description: t("trafficManagement.positionPrecisionBits.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "positionMinIntervalSecs",
|
||||
label: t("trafficManagement.positionMinIntervalSecs.label"),
|
||||
description: t("trafficManagement.positionMinIntervalSecs.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "nodeinfoDirectResponse",
|
||||
label: t("trafficManagement.nodeinfoDirectResponse.label"),
|
||||
description: t("trafficManagement.nodeinfoDirectResponse.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "nodeinfoDirectResponseMaxHops",
|
||||
label: t("trafficManagement.nodeinfoDirectResponseMaxHops.label"),
|
||||
description: t("trafficManagement.nodeinfoDirectResponseMaxHops.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "rateLimitEnabled",
|
||||
label: t("trafficManagement.rateLimitEnabled.label"),
|
||||
description: t("trafficManagement.rateLimitEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "rateLimitWindowSecs",
|
||||
label: t("trafficManagement.rateLimitWindowSecs.label"),
|
||||
description: t("trafficManagement.rateLimitWindowSecs.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "rateLimitMaxPackets",
|
||||
label: t("trafficManagement.rateLimitMaxPackets.label"),
|
||||
description: t("trafficManagement.rateLimitMaxPackets.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "dropUnknownEnabled",
|
||||
label: t("trafficManagement.dropUnknownEnabled.label"),
|
||||
description: t("trafficManagement.dropUnknownEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "unknownPacketThreshold",
|
||||
label: t("trafficManagement.unknownPacketThreshold.label"),
|
||||
description: t("trafficManagement.unknownPacketThreshold.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "exhaustHopTelemetry",
|
||||
label: t("trafficManagement.exhaustHopTelemetry.label"),
|
||||
description: t("trafficManagement.exhaustHopTelemetry.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "exhaustHopPosition",
|
||||
label: t("trafficManagement.exhaustHopPosition.label"),
|
||||
description: t("trafficManagement.exhaustHopPosition.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "routerPreserveHops",
|
||||
label: t("trafficManagement.routerPreserveHops.label"),
|
||||
description: t("trafficManagement.routerPreserveHops.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type BluetoothValidation,
|
||||
BluetoothValidationSchema,
|
||||
} from "@app/validation/config/bluetooth.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BluetoothConfigProps {
|
||||
onFormInit: DynamicFormFormInit<BluetoothValidation>;
|
||||
}
|
||||
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
||||
useWaitForConfig({ configCase: "bluetooth" });
|
||||
|
||||
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
if (deepCompareConfig(config.bluetooth, data, true)) {
|
||||
removeChange({ type: "config", variant: "bluetooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<BluetoothValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={BluetoothValidationSchema}
|
||||
defaultValues={config.bluetooth}
|
||||
values={getEffectiveConfig("bluetooth")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("bluetooth.title"),
|
||||
description: t("bluetooth.description"),
|
||||
notes: t("bluetooth.note"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enabled",
|
||||
label: t("bluetooth.enabled.label"),
|
||||
description: t("bluetooth.enabled.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "mode",
|
||||
label: t("bluetooth.pairingMode.label"),
|
||||
description: t("bluetooth.pairingMode.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "fixedPin",
|
||||
label: t("bluetooth.pin.label"),
|
||||
description: t("bluetooth.pin.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type DeviceValidation, DeviceValidationSchema } from "@app/validation/config/device.ts";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
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, setChange, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||
|
||||
const onSubmit = (data: DeviceValidation) => {
|
||||
if (deepCompareConfig(config.device, data, true)) {
|
||||
removeChange({ type: "config", variant: "device" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "device" }, data, config.device);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DeviceValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DeviceValidationSchema}
|
||||
defaultValues={config.device}
|
||||
values={getEffectiveConfig("device")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("device.title"),
|
||||
description: t("device.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "role",
|
||||
label: t("device.role.label"),
|
||||
description: t("device.role.description"),
|
||||
validate: validateRoleSelection,
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "buttonGpio",
|
||||
label: t("device.buttonPin.label"),
|
||||
description: t("device.buttonPin.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "buzzerGpio",
|
||||
label: t("device.buzzerPin.label"),
|
||||
description: t("device.buzzerPin.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "rebroadcastMode",
|
||||
label: t("device.rebroadcastMode.label"),
|
||||
description: t("device.rebroadcastMode.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "nodeInfoBroadcastSecs",
|
||||
label: t("device.nodeInfoBroadcastInterval.label"),
|
||||
description: t("device.nodeInfoBroadcastInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "doubleTapAsButtonPress",
|
||||
label: t("device.doubleTapAsButtonPress.label"),
|
||||
description: t("device.doubleTapAsButtonPress.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "disableTripleClick",
|
||||
label: t("device.disableTripleClick.label"),
|
||||
description: t("device.disableTripleClick.description"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "tzdef",
|
||||
label: t("device.posixTimezone.label"),
|
||||
description: t("device.posixTimezone.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 64,
|
||||
currentValueLength: getEffectiveConfig("device")?.tzdef?.length,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "ledHeartbeatDisabled",
|
||||
label: t("device.ledHeartbeatDisabled.label"),
|
||||
description: t("device.ledHeartbeatDisabled.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type DisplayValidation, DisplayValidationSchema } from "@app/validation/config/display.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DisplayConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DisplayValidation>;
|
||||
}
|
||||
export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
||||
useWaitForConfig({ configCase: "display" });
|
||||
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const onSubmit = (data: DisplayValidation) => {
|
||||
if (deepCompareConfig(config.display, data, true)) {
|
||||
removeChange({ type: "config", variant: "display" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "display" }, data, config.display);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DisplayValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DisplayValidationSchema}
|
||||
defaultValues={config.display}
|
||||
values={getEffectiveConfig("display")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("display.title"),
|
||||
description: t("display.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
name: "screenOnSecs",
|
||||
label: t("display.screenTimeout.label"),
|
||||
description: t("display.screenTimeout.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
// TODO: This field is deprecated since protobufs 2.7.4 and only has UNUSED=0 value.
|
||||
// GPS format has been moved to DeviceUIConfig.gps_format with proper enum values (DEC, DMS, UTM, MGRS, OLC, OSGR, MLS).
|
||||
// This should be removed once DeviceUI settings are implemented.
|
||||
// See: packages/protobufs/meshtastic/device_ui.proto
|
||||
{
|
||||
type: "select",
|
||||
name: "gpsFormat",
|
||||
label: t("display.gpsDisplayUnits.label"),
|
||||
description: t("display.gpsDisplayUnits.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DisplayConfig_DeprecatedGpsCoordinateFormat,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "autoScreenCarouselSecs",
|
||||
label: t("display.carouselDelay.label"),
|
||||
description: t("display.carouselDelay.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "compassNorthTop",
|
||||
label: t("display.compassNorthTop.label"),
|
||||
description: t("display.compassNorthTop.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "use12hClock",
|
||||
label: t("display.twelveHourClock.label"),
|
||||
description: t("display.twelveHourClock.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "flipScreen",
|
||||
label: t("display.flipScreen.label"),
|
||||
description: t("display.flipScreen.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "units",
|
||||
label: t("display.displayUnits.label"),
|
||||
description: t("display.displayUnits.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "oled",
|
||||
label: t("display.oledType.label"),
|
||||
description: t("display.oledType.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "displaymode",
|
||||
label: t("display.displayMode.label"),
|
||||
description: t("display.displayMode.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "headingBold",
|
||||
label: t("display.headingBold.label"),
|
||||
description: t("display.headingBold.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "wakeOnTapOrMotion",
|
||||
label: t("display.wakeOnTapOrMotion.label"),
|
||||
description: t("display.wakeOnTapOrMotion.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "enableMessageBubbles",
|
||||
label: t("display.enableMessageBubbles.label"),
|
||||
description: t("display.enableMessageBubbles.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type LoRaValidation, LoRaValidationSchema } from "@app/validation/config/lora.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LoRaConfigProps {
|
||||
onFormInit: DynamicFormFormInit<LoRaValidation>;
|
||||
}
|
||||
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
||||
useWaitForConfig({ configCase: "lora" });
|
||||
|
||||
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const onSubmit = (data: LoRaValidation) => {
|
||||
if (deepCompareConfig(config.lora, data, true)) {
|
||||
removeChange({ type: "config", variant: "lora" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "lora" }, data, config.lora);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<LoRaValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={LoRaValidationSchema}
|
||||
defaultValues={config.lora}
|
||||
values={getEffectiveConfig("lora")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("lora.title"),
|
||||
description: t("lora.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "region",
|
||||
label: t("lora.region.label"),
|
||||
description: t("lora.region.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "hopLimit",
|
||||
label: t("lora.hopLimit.label"),
|
||||
description: t("lora.hopLimit.description"),
|
||||
properties: {
|
||||
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "channelNum",
|
||||
label: t("lora.frequencySlot.label"),
|
||||
description: t("lora.frequencySlot.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "ignoreMqtt",
|
||||
label: t("lora.ignoreMqtt.label"),
|
||||
description: t("lora.ignoreMqtt.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "configOkToMqtt",
|
||||
label: t("lora.okToMqtt.label"),
|
||||
description: t("lora.okToMqtt.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("lora.waveformSettings.label"),
|
||||
description: t("lora.waveformSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "usePreset",
|
||||
label: t("lora.usePreset.label"),
|
||||
description: t("lora.usePreset.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "modemPreset",
|
||||
label: t("lora.modemPreset.label"),
|
||||
description: t("lora.modemPreset.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "usePreset",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "bandwidth",
|
||||
label: t("lora.bandwidth.label"),
|
||||
description: t("lora.bandwidth.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "usePreset",
|
||||
invert: true,
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
suffix: t("unit.kilohertz"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "spreadFactor",
|
||||
label: t("lora.spreadingFactor.label"),
|
||||
description: t("lora.spreadingFactor.description"),
|
||||
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "usePreset",
|
||||
invert: true,
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
suffix: t("unit.cps"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "codingRate",
|
||||
label: t("lora.codingRate.label"),
|
||||
description: t("lora.codingRate.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "usePreset",
|
||||
invert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("lora.radioSettings.label"),
|
||||
description: t("lora.radioSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "txEnabled",
|
||||
label: t("lora.transmitEnabled.label"),
|
||||
description: t("lora.transmitEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "txPower",
|
||||
label: t("lora.transmitPower.label"),
|
||||
description: t("lora.transmitPower.description"),
|
||||
properties: {
|
||||
suffix: t("unit.dbm"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "overrideDutyCycle",
|
||||
label: t("lora.overrideDutyCycle.label"),
|
||||
description: t("lora.overrideDutyCycle.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "frequencyOffset",
|
||||
label: t("lora.frequencyOffset.label"),
|
||||
description: t("lora.frequencyOffset.description"),
|
||||
properties: {
|
||||
suffix: t("unit.hertz"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "sx126xRxBoostedGain",
|
||||
label: t("lora.boostedRxGain.label"),
|
||||
description: t("lora.boostedRxGain.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "overrideFrequency",
|
||||
label: t("lora.overrideFrequency.label"),
|
||||
description: t("lora.overrideFrequency.description"),
|
||||
properties: {
|
||||
suffix: t("unit.megahertz"),
|
||||
step: 0.001,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "femLnaMode",
|
||||
label: t("lora.femLnaMode.label"),
|
||||
description: t("lora.femLnaMode.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "serialHalOnly",
|
||||
label: t("lora.serialHalOnly.label"),
|
||||
description: t("lora.serialHalOnly.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type NetworkValidation, NetworkValidationSchema } from "@app/validation/config/network.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
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";
|
||||
|
||||
interface NetworkConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NetworkValidation>;
|
||||
}
|
||||
export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||
useWaitForConfig({ configCase: "network" });
|
||||
|
||||
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const networkConfig = getEffectiveConfig("network");
|
||||
|
||||
const onSubmit = (data: NetworkValidation) => {
|
||||
const payload = {
|
||||
...data,
|
||||
ipv4Config: create(Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema, {
|
||||
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
|
||||
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
|
||||
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
|
||||
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||
}),
|
||||
};
|
||||
|
||||
if (deepCompareConfig(config.network, payload, true)) {
|
||||
removeChange({ type: "config", variant: "network" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "network" }, payload, config.network);
|
||||
};
|
||||
return (
|
||||
<DynamicForm<NetworkValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NetworkValidationSchema}
|
||||
defaultValues={{
|
||||
...config.network,
|
||||
ipv4Config: {
|
||||
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
|
||||
gateway: convertIntToIpAddress(config.network?.ipv4Config?.gateway ?? 0),
|
||||
subnet: convertIntToIpAddress(config.network?.ipv4Config?.subnet ?? 0),
|
||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||
},
|
||||
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
|
||||
}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("network.title"),
|
||||
description: t("network.description"),
|
||||
notes: t("network.note"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "wifiEnabled",
|
||||
label: t("network.wifiEnabled.label"),
|
||||
description: t("network.wifiEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "wifiSsid",
|
||||
label: t("network.ssid.label"),
|
||||
description: t("network.ssid.label"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "wifiEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
name: "wifiPsk",
|
||||
label: t("network.psk.label"),
|
||||
description: t("network.psk.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "wifiEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("network.ethernetConfigSettings.label"),
|
||||
description: t("network.ethernetConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "ethEnabled",
|
||||
label: t("network.ethernetEnabled.label"),
|
||||
description: t("network.ethernetEnabled.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("network.ipConfigSettings.label"),
|
||||
description: t("network.ipConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "addressMode",
|
||||
label: t("network.addressMode.label"),
|
||||
description: t("network.addressMode.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "ipv4Config.ip",
|
||||
label: t("network.ip.label"),
|
||||
description: t("network.ip.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "addressMode",
|
||||
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "ipv4Config.gateway",
|
||||
label: t("network.gateway.label"),
|
||||
description: t("network.gateway.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "addressMode",
|
||||
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "ipv4Config.subnet",
|
||||
label: t("network.subnet.label"),
|
||||
description: t("network.subnet.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "addressMode",
|
||||
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "ipv4Config.dns",
|
||||
label: t("network.dns.label"),
|
||||
description: t("network.dns.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "addressMode",
|
||||
selector: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("network.udpConfigSettings.label"),
|
||||
description: t("network.udpConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "enabledProtocols",
|
||||
label: t("network.meshViaUdp.label"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("network.ntpConfigSettings.label"),
|
||||
description: t("network.ntpConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
name: "ntpServer",
|
||||
label: t("network.ntpServer.label"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("network.rsyslogConfigSettings.label"),
|
||||
description: t("network.rsyslogConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
name: "rsyslogServer",
|
||||
label: t("network.rsyslogServer.label"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type PositionValidation,
|
||||
PositionValidationSchema,
|
||||
} from "@app/validation/config/position.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { type FlagName, usePositionFlags } from "@core/hooks/usePositionFlags.ts";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PositionConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PositionValidation>;
|
||||
}
|
||||
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||
useWaitForConfig({ configCase: "position" });
|
||||
|
||||
const { setChange, config, getEffectiveConfig, removeChange, queueAdminMessage } = useDevice();
|
||||
const { getMyNode } = useNodeDB();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||
);
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const myNode = getMyNode();
|
||||
const currentPosition = myNode?.position;
|
||||
|
||||
const effectiveConfig = getEffectiveConfig("position");
|
||||
const displayUnits = getEffectiveConfig("display")?.units;
|
||||
|
||||
const formValues = useMemo(() => {
|
||||
return {
|
||||
...config.position,
|
||||
...effectiveConfig,
|
||||
// Include current position coordinates if available
|
||||
latitude: currentPosition?.latitudeI ? currentPosition.latitudeI / 1e7 : undefined,
|
||||
longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined,
|
||||
altitude: currentPosition?.altitude ?? 0,
|
||||
} as PositionValidation;
|
||||
}, [config.position, effectiveConfig, currentPosition]);
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
// Exclude position coordinates from config payload (they're handled via admin message)
|
||||
const { latitude: _latitude, longitude: _longitude, altitude: _altitude, ...configData } = data;
|
||||
const payload = { ...configData, positionFlags: flagsValue };
|
||||
|
||||
// Save config first
|
||||
let configResult: ReturnType<typeof setChange> | undefined;
|
||||
if (deepCompareConfig(config.position, payload, true)) {
|
||||
removeChange({ type: "config", variant: "position" });
|
||||
configResult = undefined;
|
||||
} else {
|
||||
configResult = setChange({ type: "config", variant: "position" }, payload, config.position);
|
||||
}
|
||||
|
||||
// Then handle position coordinates via admin message if fixedPosition is enabled
|
||||
if (data.fixedPosition && data.latitude !== undefined && data.longitude !== undefined) {
|
||||
const message = create(Protobuf.Admin.AdminMessageSchema, {
|
||||
payloadVariant: {
|
||||
case: "setFixedPosition",
|
||||
value: create(Protobuf.Mesh.PositionSchema, {
|
||||
latitudeI: Math.round(data.latitude * 1e7),
|
||||
longitudeI: Math.round(data.longitude * 1e7),
|
||||
altitude: data.altitude || 0,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
queueAdminMessage(message);
|
||||
}
|
||||
|
||||
return configResult;
|
||||
};
|
||||
|
||||
const onPositonFlagChange = useCallback(
|
||||
(name: string) => {
|
||||
return toggleFlag(name as FlagName);
|
||||
},
|
||||
[toggleFlag],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicForm<PositionValidation>
|
||||
onSubmit={(data) => {
|
||||
data.positionFlags = flagsValue;
|
||||
return onSubmit(data);
|
||||
}}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PositionValidationSchema}
|
||||
defaultValues={config.position}
|
||||
values={formValues}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("position.title"),
|
||||
description: t("position.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "positionBroadcastSmartEnabled",
|
||||
label: t("position.smartPositionEnabled.label"),
|
||||
description: t("position.smartPositionEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "gpsMode",
|
||||
label: t("position.gpsMode.label"),
|
||||
description: t("position.gpsMode.description"),
|
||||
properties: {
|
||||
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "fixedPosition",
|
||||
label: t("position.fixedPosition.label"),
|
||||
description: t("position.fixedPosition.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "gpsMode",
|
||||
selector: Protobuf.Config.Config_PositionConfig_GpsMode.ENABLED,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Position coordinate fields (only shown when fixedPosition is enabled)
|
||||
{
|
||||
type: "number",
|
||||
name: "latitude",
|
||||
label: t("position.fixedPosition.latitude.label"),
|
||||
description: `${t("position.fixedPosition.latitude.description")} (Max 7 decimal precision)`,
|
||||
properties: {
|
||||
step: 0.0000001,
|
||||
suffix: "Degrees",
|
||||
fieldLength: {
|
||||
max: 10,
|
||||
},
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "fixedPosition",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "longitude",
|
||||
label: t("position.fixedPosition.longitude.label"),
|
||||
description: `${t("position.fixedPosition.longitude.description")} (Max 7 decimal precision)`,
|
||||
properties: {
|
||||
step: 0.0000001,
|
||||
suffix: "Degrees",
|
||||
fieldLength: {
|
||||
max: 10,
|
||||
},
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "fixedPosition",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "altitude",
|
||||
label: t("position.fixedPosition.altitude.label"),
|
||||
description: t("position.fixedPosition.altitude.description", {
|
||||
unit:
|
||||
displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
|
||||
? "Feet"
|
||||
: "Meters",
|
||||
}),
|
||||
properties: {
|
||||
step: 0.0000001,
|
||||
suffix:
|
||||
displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
|
||||
? "Feet"
|
||||
: "Meters",
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "fixedPosition",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "multiSelect",
|
||||
name: "positionFlags",
|
||||
value: activeFlags,
|
||||
isChecked: (name: string) => activeFlags?.includes(name as FlagName) ?? false,
|
||||
onValueChange: onPositonFlagChange,
|
||||
label: t("position.positionFlags.label"),
|
||||
placeholder: t("position.flags.placeholder"),
|
||||
description: t("position.positionFlags.description"),
|
||||
properties: {
|
||||
enumValue: getAllFlags(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "rxGpio",
|
||||
label: t("position.receivePin.label"),
|
||||
description: t("position.receivePin.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "txGpio",
|
||||
label: t("position.transmitPin.label"),
|
||||
description: t("position.transmitPin.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "gpsEnGpio",
|
||||
label: t("position.enablePin.label"),
|
||||
description: t("position.enablePin.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("position.intervalsSettings.label"),
|
||||
description: t("position.intervalsSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
name: "positionBroadcastSecs",
|
||||
label: t("position.broadcastInterval.label"),
|
||||
description: t("position.broadcastInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "gpsUpdateInterval",
|
||||
label: t("position.gpsUpdateInterval.label"),
|
||||
description: t("position.gpsUpdateInterval.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "broadcastSmartMinimumDistance",
|
||||
label: t("position.smartPositionMinDistance.label"),
|
||||
description: t("position.smartPositionMinDistance.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "positionBroadcastSmartEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "broadcastSmartMinimumIntervalSecs",
|
||||
label: t("position.smartPositionMinInterval.label"),
|
||||
description: t("position.smartPositionMinInterval.description"),
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "positionBroadcastSmartEnabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import { type PowerValidation, PowerValidationSchema } from "@app/validation/config/power.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PowerConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PowerValidation>;
|
||||
}
|
||||
export const Power = ({ onFormInit }: PowerConfigProps) => {
|
||||
useWaitForConfig({ configCase: "power" });
|
||||
|
||||
const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const onSubmit = (data: PowerValidation) => {
|
||||
if (deepCompareConfig(config.power, data, true)) {
|
||||
removeChange({ type: "config", variant: "power" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "power" }, data, config.power);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<PowerValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PowerValidationSchema}
|
||||
defaultValues={config.power}
|
||||
values={getEffectiveConfig("power")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("power.powerConfigSettings.label"),
|
||||
description: t("power.powerConfigSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isPowerSaving",
|
||||
label: t("power.powerSavingEnabled.label"),
|
||||
description: t("power.powerSavingEnabled.description"),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "onBatteryShutdownAfterSecs",
|
||||
label: t("power.shutdownOnBatteryDelay.label"),
|
||||
description: t("power.shutdownOnBatteryDelay.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "adcMultiplierOverride",
|
||||
label: t("power.adcMultiplierOverride.label"),
|
||||
description: t("power.adcMultiplierOverride.description"),
|
||||
properties: {
|
||||
step: 0.0001,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "waitBluetoothSecs",
|
||||
label: t("power.noConnectionBluetoothDisabled.label"),
|
||||
description: t("power.noConnectionBluetoothDisabled.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "deviceBatteryInaAddress",
|
||||
label: t("power.ina219Address.label"),
|
||||
description: t("power.ina219Address.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("power.sleepSettings.label"),
|
||||
description: t("power.sleepSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
name: "sdsSecs",
|
||||
label: t("power.superDeepSleepDuration.label"),
|
||||
description: t("power.superDeepSleepDuration.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "lsSecs",
|
||||
label: t("power.lightSleepDuration.label"),
|
||||
description: t("power.lightSleepDuration.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "minWakeSecs",
|
||||
label: t("power.minimumWakeTime.label"),
|
||||
description: t("power.minimumWakeTime.description"),
|
||||
properties: {
|
||||
suffix: t("unit.second.plural"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
|
||||
import {
|
||||
type ParsedSecurity,
|
||||
type RawSecurity,
|
||||
RawSecuritySchema,
|
||||
} from "@app/validation/config/security.ts";
|
||||
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 { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type DefaultValues, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SecurityConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||
}
|
||||
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
useWaitForConfig({ configCase: "security" });
|
||||
|
||||
const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } = useDevice();
|
||||
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const defaultConfig = config.security;
|
||||
const defaultValues = {
|
||||
...defaultConfig,
|
||||
...{
|
||||
privateKey: fromByteArray(defaultConfig?.privateKey ?? new Uint8Array(0)),
|
||||
publicKey: fromByteArray(defaultConfig?.publicKey ?? new Uint8Array(0)),
|
||||
adminKey: [
|
||||
fromByteArray(defaultConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
|
||||
fromByteArray(defaultConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
|
||||
fromByteArray(defaultConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const effectiveConfig = getEffectiveConfig("security");
|
||||
const formValues = {
|
||||
...effectiveConfig,
|
||||
...{
|
||||
privateKey: fromByteArray(effectiveConfig?.privateKey ?? new Uint8Array(0)),
|
||||
publicKey: fromByteArray(effectiveConfig?.publicKey ?? new Uint8Array(0)),
|
||||
adminKey: [
|
||||
fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
|
||||
fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
|
||||
fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const formMethods = useForm<RawSecurity>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues as DefaultValues<RawSecurity>,
|
||||
resolver: createZodResolver(RawSecuritySchema),
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values: formValues as RawSecurity,
|
||||
});
|
||||
const { setValue, trigger, handleSubmit, formState } = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
onFormInit?.(formMethods);
|
||||
}, [onFormInit, formMethods]);
|
||||
|
||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(false);
|
||||
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const onSubmit = (data: RawSecurity) => {
|
||||
if (!formState.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ParsedSecurity = {
|
||||
...data,
|
||||
privateKey: toByteArray(data.privateKey),
|
||||
publicKey: toByteArray(data.publicKey),
|
||||
adminKey: [
|
||||
toByteArray(data.adminKey.at(0) ?? ""),
|
||||
toByteArray(data.adminKey.at(1) ?? ""),
|
||||
toByteArray(data.adminKey.at(2) ?? ""),
|
||||
],
|
||||
};
|
||||
|
||||
if (deepCompareConfig(config.security, payload, true)) {
|
||||
removeChange({ type: "config", variant: "security" });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "config", variant: "security" }, payload, config.security);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
const privateKey = getX25519PrivateKey();
|
||||
updatePublicKey(fromByteArray(privateKey));
|
||||
};
|
||||
|
||||
const updatePublicKey = async (privateKey: string) => {
|
||||
try {
|
||||
const publicKey = fromByteArray(getX25519PublicKey(toByteArray(privateKey)));
|
||||
setValue("privateKey", privateKey, { shouldDirty: true });
|
||||
setValue("publicKey", publicKey, { shouldDirty: true });
|
||||
|
||||
setPrivateKeyDialogOpen(false);
|
||||
} catch {
|
||||
setValue("privateKey", privateKey, { shouldDirty: true });
|
||||
}
|
||||
const valid = await trigger(["privateKey", "publicKey"]);
|
||||
if (valid) {
|
||||
handleSubmit(onSubmit)(); // manually invoke form submit
|
||||
}
|
||||
};
|
||||
|
||||
const bits = [
|
||||
{
|
||||
text: t("security.256bit"),
|
||||
value: "32",
|
||||
key: "bit256",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<RawSecurity>
|
||||
propMethods={formMethods}
|
||||
onSubmit={onSubmit}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("security.title"),
|
||||
description: t("security.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
id: "pskInput",
|
||||
name: "privateKey",
|
||||
label: t("security.privateKey.label"),
|
||||
description: t("security.privateKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
hide: true,
|
||||
inputChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updatePublicKey(e.target.value);
|
||||
},
|
||||
actionButtons: [
|
||||
{
|
||||
text: t("button.generate"),
|
||||
onClick: () => setPrivateKeyDialogOpen(true),
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
text: t("button.backupKey"),
|
||||
onClick: () => setDialogOpen("pkiBackup", true),
|
||||
variant: "subtle",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
showPasswordToggle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "publicKey",
|
||||
label: t("security.publicKey.label"),
|
||||
disabled: true,
|
||||
description: t("security.publicKey.description"),
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("security.adminSettings.label"),
|
||||
description: t("security.adminSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey.0",
|
||||
id: "adminKey0Input",
|
||||
label: t("security.primaryAdminKey.label"),
|
||||
description: t("security.primaryAdminKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
actionButtons: [],
|
||||
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey.1",
|
||||
id: "adminKey1Input",
|
||||
label: t("security.secondaryAdminKey.label"),
|
||||
description: t("security.secondaryAdminKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
actionButtons: [],
|
||||
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey.2",
|
||||
id: "adminKey2Input",
|
||||
label: t("security.tertiaryAdminKey.label"),
|
||||
description: t("security.tertiaryAdminKey.description"),
|
||||
bits,
|
||||
devicePSKBitCount: 32,
|
||||
actionButtons: [],
|
||||
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isManaged",
|
||||
label: t("security.managed.label"),
|
||||
description: t("security.managed.description"),
|
||||
inputChange: (checked) => {
|
||||
if (checked) {
|
||||
setManagedModeDialogOpen(true);
|
||||
}
|
||||
|
||||
setValue("isManaged", false);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "adminChannelEnabled",
|
||||
label: t("security.adminChannelEnabled.label"),
|
||||
description: t("security.adminChannelEnabled.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("security.loggingSettings.label"),
|
||||
description: t("security.loggingSettings.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "debugLogApiEnabled",
|
||||
label: t("security.enableDebugLogApi.label"),
|
||||
description: t("security.enableDebugLogApi.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "serialEnabled",
|
||||
label: t("security.serialOutputEnabled.label"),
|
||||
description: t("security.serialOutputEnabled.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
text={{
|
||||
button: t("button.regenerate"),
|
||||
title: t("pkiRegenerate.title"),
|
||||
description: t("pkiRegenerate.description"),
|
||||
}}
|
||||
open={privateKeyDialogOpen}
|
||||
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
|
||||
onSubmit={pkiRegenerate}
|
||||
/>
|
||||
|
||||
<ManagedModeDialog
|
||||
open={managedModeDialogOpen}
|
||||
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
|
||||
onSubmit={() => {
|
||||
setValue("isManaged", true);
|
||||
setManagedModeDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { type UserValidation, UserValidationSchema } from "@app/validation/config/user.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface UserConfigProps {
|
||||
onFormInit: DynamicFormFormInit<UserValidation>;
|
||||
}
|
||||
|
||||
export const User = ({ onFormInit }: UserConfigProps) => {
|
||||
const { hardware, getChange, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
const defaultUser = myNode?.user ?? {
|
||||
id: "",
|
||||
longName: "",
|
||||
shortName: "",
|
||||
isLicensed: false,
|
||||
};
|
||||
|
||||
// Get working copy from change registry
|
||||
const workingUser = getChange({ type: "user" }) as Protobuf.Mesh.User | undefined;
|
||||
|
||||
const effectiveUser = workingUser ?? defaultUser;
|
||||
|
||||
const onSubmit = (data: UserValidation) => {
|
||||
connection?.setOwner(
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<UserValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={UserValidationSchema}
|
||||
defaultValues={{
|
||||
longName: defaultUser.longName,
|
||||
shortName: defaultUser.shortName,
|
||||
isLicensed: defaultUser.isLicensed,
|
||||
isUnmessageable: false,
|
||||
}}
|
||||
values={{
|
||||
longName: effectiveUser.longName,
|
||||
shortName: effectiveUser.shortName,
|
||||
isLicensed: effectiveUser.isLicensed,
|
||||
isUnmessageable: false,
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("user.title"),
|
||||
description: t("user.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
name: "longName",
|
||||
label: t("user.longName.label"),
|
||||
description: t("user.longName.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
min: 1,
|
||||
max: 40,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "shortName",
|
||||
label: t("user.shortName.label"),
|
||||
description: t("user.shortName.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
min: 2,
|
||||
max: 4,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isUnmessageable",
|
||||
label: t("user.isUnmessageable.label"),
|
||||
description: t("user.isUnmessageable.description"),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isLicensed",
|
||||
label: t("user.isLicensed.label"),
|
||||
description: t("user.isLicensed.description"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
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";
|
||||
|
||||
export interface ActionItem {
|
||||
key: string;
|
||||
icon?: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface PageLayoutProps {
|
||||
label: string;
|
||||
actions?: ActionItem[];
|
||||
children: React.ReactNode;
|
||||
leftBar?: React.ReactNode;
|
||||
rightBar?: React.ReactNode;
|
||||
noPadding?: boolean;
|
||||
leftBarClassName?: string;
|
||||
rightBarClassName?: string;
|
||||
topBarClassName?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export const PageLayout = ({
|
||||
label,
|
||||
actions,
|
||||
children,
|
||||
leftBar,
|
||||
rightBar,
|
||||
noPadding,
|
||||
leftBarClassName,
|
||||
rightBarClassName,
|
||||
topBarClassName,
|
||||
contentClassName,
|
||||
}: PageLayoutProps) => {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<div className="flex flex-1 bg-background text-foreground overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
{leftBar && (
|
||||
<aside
|
||||
className={cn(
|
||||
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
|
||||
leftBarClassName,
|
||||
)}
|
||||
>
|
||||
{leftBar}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
|
||||
topBarClassName,
|
||||
)}
|
||||
>
|
||||
{/* Header Content */}
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<span className="text-lg font-medium text-foreground truncate px-2">{label}</span>
|
||||
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
|
||||
{actions?.map((action) => {
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
disabled={action.disabled || action.isLoading}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 py-2 px-3 rounded-md",
|
||||
"text-foreground transition-colors duration-200",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
action.className,
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
aria-label={action.ariaLabel || `Action ${action.key}`}
|
||||
aria-disabled={action.disabled}
|
||||
aria-busy={action.isLoading}
|
||||
>
|
||||
{action.icon &&
|
||||
(action.isLoading ? (
|
||||
<Spinner size="md" />
|
||||
) : (
|
||||
<action.icon className={cn("h-5 w-5", action.iconClasses)} />
|
||||
))}
|
||||
{action.label && <span className="text-sm px-1 pt-0.5">{action.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"flex-1 flex flex-col",
|
||||
"overflow-hidden",
|
||||
!noPadding && "px-2",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
{rightBar && (
|
||||
<aside
|
||||
className={cn(
|
||||
"w-56 lg:w-[270px] text-balance shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
|
||||
rightBarClassName,
|
||||
)}
|
||||
>
|
||||
{rightBar}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user