feat(connections): Add connections page (replaces new device dialog) (#919)
* feat(conn): add connection screen and logic * fixes from code review * force https * code review fixes * add http for self testing * enable deviceStore persistance * added translations * disabled feature flag * i18n updates * chore: add new folders to biome config (#910) * chore(i18n): New Crowdin Translations by GitHub Action (#908) Co-authored-by: Crowdin Bot <support+bot@crowdin.com> * fix: use correct deprecated GPS coordinate format enum (#917) The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema. Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented. * style: fix line wrapping for GPS coordinate format enum (#918) - Split long enum reference across multiple lines to improve code readability - Maintains consistent code formatting standards without changing functionality * fix(core): ensure core package works in browser (#923) * fix(core): ensure core package works in browser * style(core): revert new line removal * fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914) * feat(ui): Add UI library (#900) * feat: scaffold UI library * Update packages/ui/src/components/theme-provider.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add lock file * lint/formatting fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting/linting fixes * fixed some paring logic * fixed connection issue with serial --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Crowdin Bot <support+bot@crowdin.com> Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com> Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com> Co-authored-by: Ben Allfree <ben@benallfree.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Crowdin Bot
Kamil Dzieniszewski
Azarattum
Ben Allfree
parent
a1a646983e
commit
1af1295b8f
@@ -0,0 +1,671 @@
|
||||
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
|
||||
import { Switch } from "@app/components/UI/Switch.tsx";
|
||||
import type { 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 = "http" | "bluetooth" | "serial";
|
||||
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" });
|
||||
toast({
|
||||
title: t("addConnection.httpConnection.connectionTest.success.title"),
|
||||
description: t(
|
||||
"addConnection.httpConnection.connectionTest.success.description",
|
||||
),
|
||||
});
|
||||
} 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,27 @@
|
||||
import z from "zod";
|
||||
|
||||
export const urlOrIpv4Schema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((val) => {
|
||||
const input = val.replace(/^https?:\/\//i, ""); // remove protocol for validation
|
||||
|
||||
// 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(input) ||
|
||||
domainRegex.test(input) ||
|
||||
localDomainRegex.test(input)
|
||||
);
|
||||
}, "Must be a valid IPv4 address or domain name")
|
||||
.transform((val) => {
|
||||
return /^https?:\/\//i.test(val) ? val : `http://${val}`;
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import z from "zod";
|
||||
import { Label } from "../UI/Label.tsx";
|
||||
|
||||
export interface User {
|
||||
longName: string;
|
||||
shortName: string;
|
||||
}
|
||||
|
||||
export interface DeviceNameDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeviceNameDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeviceNameDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { hardware, connection } = useDevice();
|
||||
const { getNode } = useNodeDB();
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
|
||||
const defaultValues = {
|
||||
shortName: myNode?.user?.shortName ?? "",
|
||||
longName: myNode?.user?.longName ?? "",
|
||||
};
|
||||
|
||||
const deviceNameSchema = z.object({
|
||||
longName: z
|
||||
.string()
|
||||
.min(1, t("deviceName.validation.longNameMin"))
|
||||
.max(40, t("deviceName.validation.longNameMax")),
|
||||
shortName: z
|
||||
.string()
|
||||
.min(2, t("deviceName.validation.shortNameMin"))
|
||||
.max(4, t("deviceName.validation.shortNameMax")),
|
||||
});
|
||||
|
||||
const { getValues, reset, control, handleSubmit } = useForm<User>({
|
||||
values: defaultValues,
|
||||
resolver: zodResolver(deviceNameSchema),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
connection?.setOwner(
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("deviceName.title")}</DialogTitle>
|
||||
<DialogDescription>{t("deviceName.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
name: "longName",
|
||||
label: t("deviceName.longName"),
|
||||
type: "text",
|
||||
properties: {
|
||||
className: "text-slate-900 dark:text-slate-200",
|
||||
fieldLength: {
|
||||
currentValueLength: getValues("longName").length,
|
||||
max: 40,
|
||||
min: 1,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
name: "shortName",
|
||||
label: t("deviceName.shortName"),
|
||||
type: "text",
|
||||
properties: {
|
||||
fieldLength: {
|
||||
currentValueLength: getValues("shortName").length,
|
||||
max: 4,
|
||||
min: 1,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
name="reset"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t("button.reset")}
|
||||
</Button>
|
||||
<Button type="submit" name="save">
|
||||
{t("button.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDev
|
||||
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 { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
||||
@@ -47,12 +46,6 @@ export const DialogManager = () => {
|
||||
setDialogOpen("reboot", false);
|
||||
}}
|
||||
/>
|
||||
<DeviceNameDialog
|
||||
open={dialog.deviceName}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("deviceName", open);
|
||||
}}
|
||||
/>
|
||||
<RemoveNodeDialog
|
||||
open={dialog.nodeRemoval}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
)
|
||||
) {
|
||||
setChange(
|
||||
{ type: "channel", index: importIndex[index] ?? 0 },
|
||||
{ type: "channels", index: importIndex[index] ?? 0 },
|
||||
payload,
|
||||
channels.get(importIndex[index] ?? 0),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user