Execute oxfmt (#1166)
Ran pnpm oxfmt . This should get the web repo aligned so that we can better enforce oxfmt going forward.
This commit is contained in:
@@ -4,7 +4,10 @@ import LanguageSwitcher from "@app/components/LanguageSwitcher";
|
||||
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
|
||||
import type { Connection } from "@app/core/stores/deviceStore/types";
|
||||
import { useConnections } from "@app/pages/Connections/useConnections";
|
||||
import { connectionTypeIcon, formatConnectionSubtext } from "@app/pages/Connections/utils";
|
||||
import {
|
||||
connectionTypeIcon,
|
||||
formatConnectionSubtext,
|
||||
} from "@app/pages/Connections/utils";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -18,7 +21,13 @@ import {
|
||||
} from "@components/UI/AlertDialog.tsx";
|
||||
import { Badge } from "@components/UI/Badge.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@components/UI/Card.tsx";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@components/UI/Card.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -96,7 +105,9 @@ export const Connections = () => {
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{t("page.title")}</h1>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
{t("page.title")}
|
||||
</h1>
|
||||
<p className="lg:w-4/6 md:w-5/6 text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t("page.description")}
|
||||
</p>
|
||||
@@ -116,7 +127,9 @@ export const Connections = () => {
|
||||
{sorted.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("noConnections.title")} </CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{t("noConnections.title")}{" "}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-slate-500 dark:text-slate-400">
|
||||
{t("noConnections.description")}
|
||||
@@ -215,7 +228,10 @@ export const Connections = () => {
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
});
|
||||
if (created.status === "connected" || created.status === "configured") {
|
||||
if (
|
||||
created.status === "connected" ||
|
||||
created.status === "configured"
|
||||
) {
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
} else {
|
||||
@@ -232,7 +248,8 @@ export const Connections = () => {
|
||||
|
||||
function TypeBadge({ type }: { type: Connection["type"] }) {
|
||||
const Icon = connectionTypeIcon(type);
|
||||
const label = type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
|
||||
const label =
|
||||
type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
|
||||
return (
|
||||
<Badge variant="default" className="gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
@@ -259,8 +276,10 @@ function ConnectionCard({
|
||||
const { t } = useTranslation("connections");
|
||||
|
||||
const Icon = connectionTypeIcon(connection.type);
|
||||
const isBusy = connection.status === "connecting" || connection.status === "configuring";
|
||||
const isConnected = connection.status === "connected" || connection.status === "configured";
|
||||
const isBusy =
|
||||
connection.status === "connecting" || connection.status === "configuring";
|
||||
const isConnected =
|
||||
connection.status === "connected" || connection.status === "configured";
|
||||
const isError = connection.status === "error";
|
||||
|
||||
return (
|
||||
@@ -297,13 +316,19 @@ function ConnectionCard({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{connection.type === "http" && connection.isDefault && (
|
||||
<DropdownMenuItem className="gap-2" onClick={() => onSetDefault()}>
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => onSetDefault()}
|
||||
>
|
||||
<StarOff className="size-4" />
|
||||
{t("button.unsetDefault")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{connection.type === "http" && !connection.isDefault && (
|
||||
<DropdownMenuItem className="gap-2" onClick={() => onSetDefault()}>
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => onSetDefault()}
|
||||
>
|
||||
<Star className="size-4" />
|
||||
{t("button.setDefault")}
|
||||
</DropdownMenuItem>
|
||||
@@ -320,13 +345,17 @@ function ConnectionCard({
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConnection")}</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteConnection")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSure", { name: connection.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("button.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => onDelete()}
|
||||
@@ -343,7 +372,9 @@ function ConnectionCard({
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{connection.error ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{connection.error}</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{connection.error}
|
||||
</p>
|
||||
) : connection.lastConnectedAt ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t("lastConnectedAt", { date: "" })}{" "}
|
||||
@@ -353,7 +384,9 @@ function ConnectionCard({
|
||||
/>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t("neverConnected")}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t("neverConnected")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center gap-2 mt-auto">
|
||||
|
||||
@@ -35,7 +35,9 @@ export function useConnections() {
|
||||
const connections = useDeviceStore((s) => s.savedConnections);
|
||||
const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
|
||||
const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
|
||||
const removeSavedConnectionFromStore = useDeviceStore((s) => s.removeSavedConnection);
|
||||
const removeSavedConnectionFromStore = useDeviceStore(
|
||||
(s) => s.removeSavedConnection,
|
||||
);
|
||||
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
@@ -168,15 +170,21 @@ export function useConnections() {
|
||||
startMaintenanceHeartbeat(id, meshDevice);
|
||||
};
|
||||
|
||||
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(() =>
|
||||
markConfigured("onConfigComplete"),
|
||||
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(
|
||||
() => markConfigured("onConfigComplete"),
|
||||
);
|
||||
const unsubStatusSignal = meshDevice.meshClient.device.status.subscribe(
|
||||
(s) => {
|
||||
if (s === DeviceStatusEnum.DeviceConfigured)
|
||||
markConfigured("device.status");
|
||||
},
|
||||
);
|
||||
const unsubStatusSignal = meshDevice.meshClient.device.status.subscribe((s) => {
|
||||
if (s === DeviceStatusEnum.DeviceConfigured) markConfigured("device.status");
|
||||
});
|
||||
// Catch-up: signal subscribe doesn't fire for the current value, so
|
||||
// check synchronously if the device is already past the gate.
|
||||
if (meshDevice.meshClient.device.status.value === DeviceStatusEnum.DeviceConfigured) {
|
||||
if (
|
||||
meshDevice.meshClient.device.status.value ===
|
||||
DeviceStatusEnum.DeviceConfigured
|
||||
) {
|
||||
markConfigured("initial-check");
|
||||
}
|
||||
|
||||
@@ -205,8 +213,13 @@ export function useConnections() {
|
||||
meshDevice
|
||||
.configure()
|
||||
.then(() => {
|
||||
log.debug("setupMeshDevice: configure() resolved, sending heartbeat", { id });
|
||||
return meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice));
|
||||
log.debug(
|
||||
"setupMeshDevice: configure() resolved, sending heartbeat",
|
||||
{ id },
|
||||
);
|
||||
return meshDevice
|
||||
.heartbeat()
|
||||
.then(() => startConfigHeartbeat(id, meshDevice));
|
||||
})
|
||||
.catch((error) => {
|
||||
const e = error as Error;
|
||||
@@ -243,29 +256,53 @@ export function useConnections() {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.info("connect: enter", { id, type: conn.type, allowPrompt: !!opts?.allowPrompt });
|
||||
log.info("connect: enter", {
|
||||
id,
|
||||
type: conn.type,
|
||||
allowPrompt: !!opts?.allowPrompt,
|
||||
});
|
||||
updateStatus(id, "connecting");
|
||||
try {
|
||||
const cached = cachedTransports.get(id);
|
||||
const result = await openTransport(conn, {
|
||||
allowPrompt: opts?.allowPrompt,
|
||||
cachedBluetoothDevice:
|
||||
conn.type === "bluetooth" ? (cached as BluetoothDevice | undefined) : undefined,
|
||||
cachedSerialPort: conn.type === "serial" ? (cached as SerialPort | undefined) : undefined,
|
||||
conn.type === "bluetooth"
|
||||
? (cached as BluetoothDevice | undefined)
|
||||
: undefined,
|
||||
cachedSerialPort:
|
||||
conn.type === "serial"
|
||||
? (cached as SerialPort | undefined)
|
||||
: undefined,
|
||||
});
|
||||
log.debug("connect: openTransport ok", { id });
|
||||
await setupMeshDevice(id, result.transport, result.bluetoothDevice, result.serialPort);
|
||||
log.info("connect: setupMeshDevice resolved, awaiting onConfigComplete", { id });
|
||||
await setupMeshDevice(
|
||||
id,
|
||||
result.transport,
|
||||
result.bluetoothDevice,
|
||||
result.serialPort,
|
||||
);
|
||||
log.info(
|
||||
"connect: setupMeshDevice resolved, awaiting onConfigComplete",
|
||||
{ id },
|
||||
);
|
||||
|
||||
// BT-specific: catch device-side disconnect to flip status.
|
||||
result.bluetoothDevice?.addEventListener("gattserverdisconnected", () => {
|
||||
log.warn("BT gattserverdisconnected", { id });
|
||||
updateStatus(id, "disconnected");
|
||||
});
|
||||
result.bluetoothDevice?.addEventListener(
|
||||
"gattserverdisconnected",
|
||||
() => {
|
||||
log.warn("BT gattserverdisconnected", { id });
|
||||
updateStatus(id, "disconnected");
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
log.error("connect: failed", { id, name: e?.name, message: e?.message });
|
||||
log.error("connect: failed", {
|
||||
id,
|
||||
name: e?.name,
|
||||
message: e?.message,
|
||||
});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
updateStatus(id, "error", message);
|
||||
return false;
|
||||
@@ -306,7 +343,8 @@ export function useConnections() {
|
||||
const addConnectionAndConnect = useCallback(
|
||||
async (input: NewConnection, btDevice?: BluetoothDevice) => {
|
||||
const conn = addConnection(input);
|
||||
if (btDevice && conn.type === "bluetooth") cachedTransports.set(conn.id, btDevice);
|
||||
if (btDevice && conn.type === "bluetooth")
|
||||
cachedTransports.set(conn.id, btDevice);
|
||||
await connect(conn.id, { allowPrompt: true });
|
||||
return conn;
|
||||
},
|
||||
@@ -315,7 +353,10 @@ export function useConnections() {
|
||||
|
||||
const refreshStatuses = useCallback(async () => {
|
||||
const candidates = connections.filter(
|
||||
(c) => c.status !== "connected" && c.status !== "configured" && c.status !== "configuring",
|
||||
(c) =>
|
||||
c.status !== "connected" &&
|
||||
c.status !== "configured" &&
|
||||
c.status !== "configuring",
|
||||
);
|
||||
await Promise.all(
|
||||
candidates.map(async (c) => {
|
||||
@@ -326,7 +367,9 @@ export function useConnections() {
|
||||
}, [connections, updateSavedConnection]);
|
||||
|
||||
const syncConnectionStatuses = useCallback(() => {
|
||||
const activeConnection = connections.find((c) => c.meshDeviceId === selectedDeviceId);
|
||||
const activeConnection = connections.find(
|
||||
(c) => c.meshDeviceId === selectedDeviceId,
|
||||
);
|
||||
connections.forEach((conn) => {
|
||||
const shouldBeConnected = activeConnection?.id === conn.id;
|
||||
const isConnectedState =
|
||||
|
||||
@@ -40,7 +40,10 @@ export function createConnectionFromInput(input: NewConnection): Connection {
|
||||
};
|
||||
}
|
||||
|
||||
export async function testHttpReachable(url: string, timeoutMs = 2500): Promise<boolean> {
|
||||
export async function testHttpReachable(
|
||||
url: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
type VisibilityState,
|
||||
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { type FilterState, useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { BaseMap } from "@components/Map.tsx";
|
||||
import {
|
||||
HeatmapLayer,
|
||||
@@ -22,22 +25,38 @@ import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrap
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
|
||||
import { useMyNodeAsProto, useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import {
|
||||
useMyNodeAsProto,
|
||||
useNodesAsProto,
|
||||
} from "@core/hooks/useNodesAsProto.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/sdk";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
|
||||
|
||||
const MapPage = () => {
|
||||
const { t } = useTranslation("map");
|
||||
const allNodes = useNodesAsProto();
|
||||
const getNode = useCallback((n: number) => allNodes.find((node) => node.num === n), [allNodes]);
|
||||
const getNode = useCallback(
|
||||
(n: number) => allNodes.find((node) => node.num === n),
|
||||
[allNodes],
|
||||
);
|
||||
const validNodes = useMemo(
|
||||
() => allNodes.filter((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI)),
|
||||
() =>
|
||||
allNodes.filter((n): n is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(n.position?.latitudeI),
|
||||
),
|
||||
[allNodes],
|
||||
);
|
||||
const myNode = useMyNodeAsProto();
|
||||
@@ -56,7 +75,9 @@ const MapPage = () => {
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
|
||||
const [filterState, setFilterState] = useState<FilterState>(
|
||||
() => defaultFilterValues,
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
@@ -97,7 +118,12 @@ const MapPage = () => {
|
||||
mode={heatmapMode}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId],
|
||||
[
|
||||
filteredNodes,
|
||||
visibilityState.heatmap,
|
||||
heatmapMode,
|
||||
heatmapLayerElementId,
|
||||
],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
@@ -109,7 +135,8 @@ const MapPage = () => {
|
||||
const hoveredFeature = features?.[0];
|
||||
|
||||
if (hoveredFeature) {
|
||||
const { from, to, snr, name, shortName, num } = hoveredFeature.properties;
|
||||
const { from, to, snr, name, shortName, num } =
|
||||
hoveredFeature.properties;
|
||||
|
||||
// Handle Heatmap Hover
|
||||
if (
|
||||
@@ -169,7 +196,14 @@ const MapPage = () => {
|
||||
isVisible={visibilityState.nodeMarkers}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, expandedCluster, mapRef, myNode, popupState, visibilityState.nodeMarkers],
|
||||
[
|
||||
filteredNodes,
|
||||
expandedCluster,
|
||||
mapRef,
|
||||
myNode,
|
||||
popupState,
|
||||
visibilityState.nodeMarkers,
|
||||
],
|
||||
);
|
||||
|
||||
// Precision circles
|
||||
@@ -182,7 +216,11 @@ const MapPage = () => {
|
||||
isVisible={visibilityState.positionPrecision}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.positionPrecision, precisionCirclesElementId],
|
||||
[
|
||||
filteredNodes,
|
||||
visibilityState.positionPrecision,
|
||||
precisionCirclesElementId,
|
||||
],
|
||||
);
|
||||
|
||||
// Waypoints
|
||||
@@ -205,7 +243,10 @@ const MapPage = () => {
|
||||
onLoad={getMapBounds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onMapBackgroundClick}
|
||||
interactiveLayerIds={[snrLayerElementId, `${heatmapLayerElementId}-interaction`]}
|
||||
interactiveLayerIds={[
|
||||
snrLayerElementId,
|
||||
`${heatmapLayerElementId}-interaction`,
|
||||
]}
|
||||
>
|
||||
{heatmapLayerElement}
|
||||
{markerElements}
|
||||
@@ -214,7 +255,12 @@ const MapPage = () => {
|
||||
{waypointLayerElement}
|
||||
|
||||
{snrHover && (
|
||||
<SNRTooltip pos={snrHover.pos} snr={snrHover.snr} from={snrHover.from} to={snrHover.to} />
|
||||
<SNRTooltip
|
||||
pos={snrHover.pos}
|
||||
snr={snrHover.snr}
|
||||
from={snrHover.from}
|
||||
to={snrHover.to}
|
||||
/>
|
||||
)}
|
||||
</BaseMap>
|
||||
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
|
||||
|
||||
@@ -13,10 +13,21 @@ import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { MessageType, useSidebar } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/sdk";
|
||||
import { useActiveClient, useChannels, useNodeErrors, useUnreadByKey } from "@meshtastic/sdk-react";
|
||||
import {
|
||||
useActiveClient,
|
||||
useChannels,
|
||||
useNodeErrors,
|
||||
useUnreadByKey,
|
||||
} from "@meshtastic/sdk-react";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChannelName } from "../components/PageComponents/Channels/Channels.tsx";
|
||||
|
||||
@@ -35,8 +46,10 @@ export const MessagesPage = () => {
|
||||
const channels = useChannels();
|
||||
const unreadByKey = useUnreadByKey();
|
||||
const meshClient = useActiveClient();
|
||||
const directUnread = (peer: number): number => unreadByKey.get(`direct:${peer}`) ?? 0;
|
||||
const channelUnread = (idx: number): number => unreadByKey.get(`channel:${idx}`) ?? 0;
|
||||
const directUnread = (peer: number): number =>
|
||||
unreadByKey.get(`direct:${peer}`) ?? 0;
|
||||
const channelUnread = (idx: number): number =>
|
||||
unreadByKey.get(`channel:${idx}`) ?? 0;
|
||||
const markChannelRead = useCallback(
|
||||
(idx: number) =>
|
||||
meshClient?.chat.unread.markRead({
|
||||
@@ -46,14 +59,18 @@ export const MessagesPage = () => {
|
||||
[meshClient],
|
||||
);
|
||||
const markDirectRead = useCallback(
|
||||
(peer: number) => meshClient?.chat.unread.markRead({ kind: "direct", peer }),
|
||||
(peer: number) =>
|
||||
meshClient?.chat.unread.markRead({ kind: "direct", peer }),
|
||||
[meshClient],
|
||||
);
|
||||
const allNodes = useNodesAsProto();
|
||||
const getNode = (n: number) => allNodes.find((node) => node.num === n);
|
||||
const errors = useNodeErrors();
|
||||
const errorSet = useMemo(() => new Set(errors.map((e) => e.node)), [errors]);
|
||||
const hasNodeError = useCallback((num: number) => errorSet.has(num), [errorSet]);
|
||||
const hasNodeError = useCallback(
|
||||
(num: number) => errorSet.has(num),
|
||||
[errorSet],
|
||||
);
|
||||
|
||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||
|
||||
@@ -72,18 +89,25 @@ export const MessagesPage = () => {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const chatType = type === "direct" ? MessageType.Direct : MessageType.Broadcast;
|
||||
const chatType =
|
||||
type === "direct" ? MessageType.Direct : MessageType.Broadcast;
|
||||
const numericChatId = Number(chatId);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
() => channels.filter((ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED),
|
||||
() =>
|
||||
channels.filter(
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type && !chatId && filteredChannels.length > 0) {
|
||||
const defaultChannel = filteredChannels[0];
|
||||
navigateToChat(MessageType.Broadcast, defaultChannel?.index.toString() ?? "0");
|
||||
navigateToChat(
|
||||
MessageType.Broadcast,
|
||||
defaultChannel?.index.toString() ?? "0",
|
||||
);
|
||||
}
|
||||
}, [type, chatId, filteredChannels, navigateToChat]);
|
||||
|
||||
@@ -100,7 +124,10 @@ export const MessagesPage = () => {
|
||||
.filter((node: Protobuf.Mesh.NodeInfo) => {
|
||||
const longName = node.user?.longName?.toLowerCase() ?? "";
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? "";
|
||||
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm);
|
||||
return (
|
||||
longName.includes(lowerCaseSearchTerm) ||
|
||||
shortName.includes(lowerCaseSearchTerm)
|
||||
);
|
||||
})
|
||||
.map((node: Protobuf.Mesh.NodeInfo) => ({
|
||||
...node,
|
||||
@@ -121,9 +148,15 @@ export const MessagesPage = () => {
|
||||
console.warn("[MessagesPage] no active mesh client; send dropped");
|
||||
return;
|
||||
}
|
||||
const destination: Types.Destination = isDirect ? numericChatId : "broadcast";
|
||||
const destination: Types.Destination = isDirect
|
||||
? numericChatId
|
||||
: "broadcast";
|
||||
const channel = isDirect ? Types.ChannelNumber.Primary : numericChatId;
|
||||
const result = await meshClient.chat.send({ text: message, destination, channel });
|
||||
const result = await meshClient.chat.send({
|
||||
text: message,
|
||||
destination,
|
||||
channel,
|
||||
});
|
||||
if (result.status === "error") {
|
||||
console.error("Failed to send message:", result.error);
|
||||
}
|
||||
@@ -170,13 +203,19 @@ export const MessagesPage = () => {
|
||||
ns: "channels",
|
||||
}))
|
||||
}
|
||||
active={numericChatId === channel.index && chatType === MessageType.Broadcast}
|
||||
active={
|
||||
numericChatId === channel.index &&
|
||||
chatType === MessageType.Broadcast
|
||||
}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
markChannelRead(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
|
||||
<HashIcon
|
||||
size={16}
|
||||
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
@@ -195,7 +234,10 @@ export const MessagesPage = () => {
|
||||
);
|
||||
|
||||
const rightSidebar = (
|
||||
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto">
|
||||
<SidebarSection
|
||||
label=""
|
||||
className="px-0 flex flex-col h-full overflow-y-auto"
|
||||
>
|
||||
<label className="p-2 block" htmlFor="nodeSearch">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -207,7 +249,9 @@ export const MessagesPage = () => {
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className={cn("flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ")}
|
||||
className={cn(
|
||||
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
|
||||
)}
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "100px" }}
|
||||
>
|
||||
{filteredNodes()?.map((node) => (
|
||||
@@ -216,7 +260,9 @@ export const MessagesPage = () => {
|
||||
preventCollapse
|
||||
label={node.user?.longName ?? t("unknown.shortName")}
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined}
|
||||
active={numericChatId === node.num && chatType === MessageType.Direct}
|
||||
active={
|
||||
numericChatId === node.num && chatType === MessageType.Direct
|
||||
}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Direct, node.num.toString());
|
||||
markDirectRead(node.num);
|
||||
@@ -254,7 +300,9 @@ export const MessagesPage = () => {
|
||||
? [
|
||||
{
|
||||
key: "encryption",
|
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
|
||||
icon: otherNode.user?.publicKey?.length
|
||||
? LockIcon
|
||||
: LockOpenIcon,
|
||||
iconClasses: otherNode.user?.publicKey?.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
|
||||
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { type FilterState, useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { type DataRow, type Heading, Table } from "@components/generic/Table/index.tsx";
|
||||
import {
|
||||
type DataRow,
|
||||
type Heading,
|
||||
Table,
|
||||
} from "@components/generic/Table/index.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -16,7 +23,14 @@ import { Protobuf, type Types } from "@meshtastic/sdk";
|
||||
import { useNodeErrors } from "@meshtastic/sdk-react";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { type JSX, useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type JSX,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
@@ -40,7 +54,9 @@ const NodesPage = (): JSX.Element => {
|
||||
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
|
||||
>();
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
|
||||
const [filterState, setFilterState] = useState<FilterState>(
|
||||
() => defaultFilterValues,
|
||||
);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
// stable predicate so the selector identity doesn’t thrash
|
||||
@@ -52,10 +68,16 @@ const NodesPage = (): JSX.Element => {
|
||||
// Nodes come from the SDK NodesClient (signals + sqlocal-backed history).
|
||||
// PKI / node-error tracking lives on the SDK NodesClient too.
|
||||
const allSdkNodes = useNodesAsProto();
|
||||
const filteredNodes = useMemo(() => allSdkNodes.filter(predicate), [allSdkNodes, predicate]);
|
||||
const filteredNodes = useMemo(
|
||||
() => allSdkNodes.filter(predicate),
|
||||
[allSdkNodes, predicate],
|
||||
);
|
||||
const errors = useNodeErrors();
|
||||
const errorSet = useMemo(() => new Set(errors.map((e) => e.node)), [errors]);
|
||||
const hasNodeError = useCallback((num: number) => errorSet.has(num), [errorSet]);
|
||||
const hasNodeError = useCallback(
|
||||
(num: number) => errorSet.has(num),
|
||||
[errorSet],
|
||||
);
|
||||
const handleTraceroute = useCallback(
|
||||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
||||
setSelectedTraceroute(traceroute);
|
||||
@@ -119,7 +141,9 @@ const NodesPage = (): JSX.Element => {
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? t("unknown.shortName");
|
||||
|
||||
const shortName = node.user?.shortName ?? numberToHexUnpadded(node.num).slice(-4).toUpperCase();
|
||||
const shortName =
|
||||
node.user?.shortName ??
|
||||
numberToHexUnpadded(node.num).slice(-4).toUpperCase();
|
||||
const longName =
|
||||
node.user?.longName ??
|
||||
t("fallbackName", {
|
||||
@@ -131,7 +155,11 @@ const NodesPage = (): JSX.Element => {
|
||||
const snr = node.snr ?? 0;
|
||||
const snrQuality = Math.min(Math.max(Math.round((snr + 10) * 5), 0), 100);
|
||||
const snrTone =
|
||||
snrQuality >= 67 ? "text-green-500" : snrQuality >= 34 ? "text-yellow-500" : "text-red-500";
|
||||
snrQuality >= 67
|
||||
? "text-green-500"
|
||||
: snrQuality >= 34
|
||||
? "text-yellow-500"
|
||||
: "text-red-500";
|
||||
|
||||
return {
|
||||
id: node.num,
|
||||
@@ -170,10 +198,14 @@ const NodesPage = (): JSX.Element => {
|
||||
? node?.viaMqtt === false && node.hopsAway === 0
|
||||
? t("nodesTable.connectionStatus.direct")
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
(node.hopsAway ?? 0) > 1 ? t("unit.hop.plural") : t("unit.hop.one")
|
||||
(node.hopsAway ?? 0) > 1
|
||||
? t("unit.hop.plural")
|
||||
: t("unit.hop.one")
|
||||
} ${t("nodesTable.connectionStatus.away")}`
|
||||
: t("unknown.longName")}
|
||||
{node?.viaMqtt === true ? t("nodesTable.connectionStatus.viaMqtt") : ""}
|
||||
{node?.viaMqtt === true
|
||||
? t("nodesTable.connectionStatus.viaMqtt")
|
||||
: ""}
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
|
||||
@@ -184,7 +216,10 @@ const NodesPage = (): JSX.Element => {
|
||||
{node.lastHeard === 0 ? (
|
||||
t("unknown.longName")
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} locale={current?.code} />
|
||||
<TimeAgo
|
||||
timestamp={node.lastHeard * 1000}
|
||||
locale={current?.code}
|
||||
/>
|
||||
)}
|
||||
</Mono>
|
||||
),
|
||||
@@ -208,14 +243,19 @@ const NodesPage = (): JSX.Element => {
|
||||
<span className={snrTone}>
|
||||
{Number(snr.toFixed(1))} {t("unit.db")}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400/70">{snrQuality}%</span>
|
||||
<span className="text-gray-500 dark:text-gray-400/70">
|
||||
{snrQuality}%
|
||||
</span>
|
||||
</Mono>
|
||||
),
|
||||
sortValue: node.snr,
|
||||
},
|
||||
{
|
||||
content: <Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>,
|
||||
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNSET",
|
||||
content: (
|
||||
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>
|
||||
),
|
||||
sortValue:
|
||||
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "UNSET",
|
||||
},
|
||||
{
|
||||
content: <Mono>{macAddress}</Mono>,
|
||||
|
||||
@@ -6,7 +6,12 @@ import { Position } from "@components/PageComponents/Settings/Position.tsx";
|
||||
import { Power } from "@components/PageComponents/Settings/Power.tsx";
|
||||
import { User } from "@components/PageComponents/Settings/User.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import type { ValidConfigType } from "@core/stores";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
@@ -38,8 +43,12 @@ const EMPTY_OWNER_DIRTY_SIGNAL = {
|
||||
|
||||
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const editor = useConfigEditor();
|
||||
const dirtyRadio = useSignal(editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL);
|
||||
const isOwnerDirty = useSignal(editor?.isOwnerDirty ?? EMPTY_OWNER_DIRTY_SIGNAL);
|
||||
const dirtyRadio = useSignal(
|
||||
editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL,
|
||||
);
|
||||
const isOwnerDirty = useSignal(
|
||||
editor?.isOwnerDirty ?? EMPTY_OWNER_DIRTY_SIGNAL,
|
||||
);
|
||||
const { t } = useTranslation("config");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
@@ -87,7 +96,9 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
new Map(
|
||||
tabs.map((tab) => [
|
||||
tab.case,
|
||||
tab.case === "user" ? isOwnerDirty : dirtyRadio.includes(tab.case as ValidConfigType),
|
||||
tab.case === "user"
|
||||
? isOwnerDirty
|
||||
: dirtyRadio.includes(tab.case as ValidConfigType),
|
||||
]),
|
||||
),
|
||||
[tabs, dirtyRadio, isOwnerDirty],
|
||||
@@ -97,7 +108,11 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
<Tabs defaultValue={t("page.tabUser")}>
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
|
||||
@@ -15,7 +15,12 @@ import { Tak } from "@components/PageComponents/ModuleConfig/Tak.tsx";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
|
||||
import { TrafficManagement } from "@components/PageComponents/ModuleConfig/TrafficManagement.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import type { ValidModuleConfigType } from "@core/stores";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
@@ -41,7 +46,9 @@ const EMPTY_DIRTY_MODULE_SIGNAL = {
|
||||
|
||||
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const editor = useConfigEditor();
|
||||
const dirtyModule = useSignal(editor?.dirtyModuleSections ?? EMPTY_DIRTY_MODULE_SIGNAL);
|
||||
const dirtyModule = useSignal(
|
||||
editor?.dirtyModuleSections ?? EMPTY_DIRTY_MODULE_SIGNAL,
|
||||
);
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
@@ -100,7 +107,11 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
label: t("page.tabDetectionSensor"),
|
||||
element: DetectionSensor,
|
||||
},
|
||||
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
|
||||
{
|
||||
case: "paxcounter",
|
||||
label: t("page.tabPaxcounter"),
|
||||
element: Paxcounter,
|
||||
},
|
||||
{
|
||||
case: "remoteHardware",
|
||||
label: t("page.tabRemoteHardware"),
|
||||
@@ -122,7 +133,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
);
|
||||
|
||||
const flags = useMemo(
|
||||
() => new Map(tabs.map((tab) => [tab.case, dirtyModule.includes(tab.case)])),
|
||||
() =>
|
||||
new Map(tabs.map((tab) => [tab.case, dirtyModule.includes(tab.case)])),
|
||||
[tabs, dirtyModule],
|
||||
);
|
||||
|
||||
@@ -130,7 +142,11 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||
<TabsList className="w-full dark:bg-slate-800">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Channels } from "@app/components/PageComponents/Channels/Channels";
|
||||
import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx";
|
||||
import { Security } from "@components/PageComponents/Settings/Security/Security.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import type { ValidConfigType } from "@core/stores";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { type ComponentType, Suspense, useMemo } from "react";
|
||||
@@ -34,8 +39,12 @@ const EMPTY_DIRTY_CHANNELS_SIGNAL = {
|
||||
|
||||
export const RadioConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const editor = useConfigEditor();
|
||||
const dirtyRadio = useSignal(editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL);
|
||||
const dirtyChannels = useSignal(editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL);
|
||||
const dirtyRadio = useSignal(
|
||||
editor?.dirtyRadioSections ?? EMPTY_DIRTY_RADIO_SIGNAL,
|
||||
);
|
||||
const dirtyChannels = useSignal(
|
||||
editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL,
|
||||
);
|
||||
const { t } = useTranslation("config");
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
@@ -63,7 +72,9 @@ export const RadioConfig = ({ onFormInit }: ConfigProps) => {
|
||||
new Map(
|
||||
tabs.map((tab) => [
|
||||
tab.case,
|
||||
tab.case === "channels" ? dirtyChannels.length > 0 : dirtyRadio.includes(tab.case),
|
||||
tab.case === "channels"
|
||||
? dirtyChannels.length > 0
|
||||
: dirtyRadio.includes(tab.case),
|
||||
]),
|
||||
),
|
||||
[tabs, dirtyRadio, dirtyChannels],
|
||||
@@ -73,7 +84,11 @@ export const RadioConfig = ({ onFormInit }: ConfigProps) => {
|
||||
<Tabs defaultValue={t("page.tabLora")}>
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label} className="dark:text-white relative">
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
value={tab.label}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{tab.label}
|
||||
{flags.get(tab.case) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
|
||||
@@ -36,11 +36,21 @@ const EMPTY_DIRTY_NUMBER_SIGNAL = {
|
||||
const ConfigPage = () => {
|
||||
const editor = useConfigEditor();
|
||||
const editorIsDirty = useSignal(
|
||||
editor?.isDirty ?? { value: false, peek: () => false, subscribe: () => () => {} },
|
||||
editor?.isDirty ?? {
|
||||
value: false,
|
||||
peek: () => false,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
);
|
||||
const dirtyRadio = useSignal(
|
||||
editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL,
|
||||
);
|
||||
const dirtyModule = useSignal(
|
||||
editor?.dirtyModuleSections ?? EMPTY_DIRTY_STRING_SIGNAL,
|
||||
);
|
||||
const dirtyChannels = useSignal(
|
||||
editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL,
|
||||
);
|
||||
const dirtyRadio = useSignal(editor?.dirtyRadioSections ?? EMPTY_DIRTY_STRING_SIGNAL);
|
||||
const dirtyModule = useSignal(editor?.dirtyModuleSections ?? EMPTY_DIRTY_STRING_SIGNAL);
|
||||
const dirtyChannels = useSignal(editor?.dirtyChannels ?? EMPTY_DIRTY_NUMBER_SIGNAL);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
|
||||
@@ -90,27 +100,30 @@ const ConfigPage = () => {
|
||||
routerState.location.pathname.includes(`/settings/${section.key}`),
|
||||
) ?? sections[0];
|
||||
|
||||
const onFormInit = useCallback(<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||
setFormMethods(methods as UseFormReturn);
|
||||
const onFormInit = useCallback(
|
||||
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||
setFormMethods(methods as UseFormReturn);
|
||||
|
||||
setRhfState({
|
||||
// Assume defailt on init, changes will be caught by subscription
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
setRhfState({
|
||||
// Assume defailt on init, changes will be caught by subscription
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
// Unsubscribe from previous subscriptions & subscribe to form changes
|
||||
unsubRef.current?.();
|
||||
unsubRef.current = methods.subscribe({
|
||||
formState: { isDirty: true, isValid: true },
|
||||
callback: ({ isValid, isDirty }) => {
|
||||
setRhfState({
|
||||
isDirty: isDirty ?? false,
|
||||
isValid: isValid ?? true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
// Unsubscribe from previous subscriptions & subscribe to form changes
|
||||
unsubRef.current?.();
|
||||
unsubRef.current = methods.subscribe({
|
||||
formState: { isDirty: true, isValid: true },
|
||||
callback: ({ isValid, isDirty }) => {
|
||||
setRhfState({
|
||||
isDirty: isDirty ?? false,
|
||||
isValid: isValid ?? true,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => unsubRef.current?.();
|
||||
@@ -212,7 +225,9 @@ const ConfigPage = () => {
|
||||
isLoading: isSaving,
|
||||
disabled: saveDisabled,
|
||||
iconClasses:
|
||||
!rhfState.isValid && hasPending ? "text-red-400 cursor-not-allowed" : "cursor-pointer",
|
||||
!rhfState.isValid && hasPending
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
|
||||
Reference in New Issue
Block a user