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:
Austin
2026-06-16 20:30:08 -04:00
committed by GitHub
parent c5690d1edb
commit d2cb51d489
378 changed files with 5548 additions and 1974 deletions
+47 -14
View File
@@ -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 =
+4 -1
View File
@@ -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);
+58 -12
View File
@@ -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">
+66 -18
View File
@@ -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",
+54 -14
View File
@@ -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>,
+20 -5
View File
@@ -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">
+21 -5
View File
@@ -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">
+20 -5
View File
@@ -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">
+39 -24
View File
@@ -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",