New channel config (#807)

* Channel config rework

Add staged channel config with tabbed UI, import/export workflow, and global form state refactor

* Improve import dialog config comparison and UI labels

* Review fixes

* Improve state handling

* Fix default filter behaviour

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-09-01 09:07:34 -04:00
committed by GitHub
co-authored by philon-
parent 01fa030ef9
commit 1946000d14
42 changed files with 597 additions and 456 deletions
@@ -11,13 +11,20 @@ import {
} 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 { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import { Trans, useTranslation } from "react-i18next";
export interface ImportDialogProps {
open: boolean;
@@ -26,12 +33,15 @@ export interface ImportDialogProps {
}
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { config, channels } = 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[]>([]);
const { connection } = useDevice();
const { setWorkingChannelConfig, setWorkingConfig } = useDevice();
useEffect(() => {
// the channel information is contained in the URL's fragment, which will be present after a
@@ -55,12 +65,17 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
)
.replace(/-/g, "+")
.replace(/_/g, "/");
setChannelSet(
fromBinary(
Protobuf.AppOnly.ChannelSetSchema,
toByteArray(paddedString),
),
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 (_error) {
setValidUrl(false);
@@ -71,29 +86,62 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const apply = () => {
channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role:
index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
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,
)
) {
setWorkingChannelConfig(payload);
}
},
);
if (channelSet?.loraConfig) {
connection?.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: channelSet.loraConfig,
},
}),
);
if (channelSet?.loraConfig && updateConfig) {
const payload = {
...config.lora,
...channelSet.loraConfig,
};
if (!deepCompareConfig(config.lora, payload, true)) {
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: payload,
},
}),
);
}
}
// 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);
setImportIndex(newImportIndex);
};
return (
@@ -102,55 +150,85 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>{t("import.description")}</DialogDescription>
<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}
suffix={validUrl ? "✅" : "❌"}
variant={
importDialogInput === ""
? "default"
: validUrl
? "dirty"
: "invalid"
}
onChange={(e) => {
setImportDialogInput(e.target.value);
}}
/>
{validUrl && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-6 mt-2">
<div className="flex w-full gap-2">
<div className="w-36">
<Label>{t("import.usePreset")}</Label>
<div className=" flex items-center">
<Switch
disabled
checked={channelSet?.loraConfig?.usePreset ?? true}
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>
{/* <Select
label="Modem Preset"
disabled
value={channelSet?.loraConfig?.modemPreset}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> */}
</div>
{/* <Select
label="Region"
disabled
value={channelSet?.loraConfig?.region}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> */}
<span className="text-md block font-medium text-text-primary">
{t("import.channels")}
</span>
<div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => (
<div className="flex justify-between" key={channel.id}>
<Label>
<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>
<Checkbox key={channel.id} />
<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>
@@ -82,7 +82,7 @@ export const QRDialog = ({
: `${t("page.channelIndex", {
ns: "channels",
index: channel.index,
})}${channel.index}`}
})}`}
</Label>
<Checkbox
key={channel.index}