fix: resolve lint warnings/errors and apply formatting (#1024)

* fix: resolve lint warnings/errors and apply formatting

Fix 10 oxlint issues (2 errors, 8 warnings):
- Remove unused catch parameters in Security.tsx and ImportDialog.tsx
- Remove stray expression in Generator.tsx
- Add eslint-disable for debounced useCallback in FilterControl.tsx
- Remove unnecessary deps (resolveDB, store) in bindStoreToDevice.ts
- Prefix unused variant param in AppSidebar.tsx
- Memoize tabs arrays in DeviceConfig, RadioConfig, ModuleConfig
- Fix channels type in RadioConfig TabItem

Also applies oxfmt formatting across all files.

* updating lock file

* update protobuf package

* fix: regenerate pnpm-lock.yaml and exclude jsr protobufs from minimumReleaseAge

* prevented http card from always being polled

* updated pnpm config fiile

* updated actions

* removed biome config and lint system leftovers

* updating protobuf package
This commit is contained in:
Dan Ditomaso
2026-03-11 21:54:25 -04:00
committed by GitHub
parent a90df07ebe
commit c729d3b25e
256 changed files with 2164 additions and 4362 deletions
+16 -48
View File
@@ -4,10 +4,7 @@ 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,
@@ -21,13 +18,7 @@ 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,
@@ -68,11 +59,12 @@ export const Connections = () => {
const { t } = useTranslation("connections");
// On first mount, sync statuses and refresh
// biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
useEffect(() => {
syncConnectionStatuses();
refreshStatuses();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sorted = useMemo(() => {
const copy = [...connections];
return copy.sort((a, b) => {
@@ -104,9 +96,7 @@ 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>
@@ -126,9 +116,7 @@ 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")}
@@ -227,10 +215,7 @@ export const Connections = () => {
interpolation: { escapeValue: false },
}),
});
if (
created.status === "connected" ||
created.status === "configured"
) {
if (created.status === "connected" || created.status === "configured") {
navigate({ to: "/" });
}
} else {
@@ -247,8 +232,7 @@ 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" />
@@ -275,10 +259,8 @@ 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 (
@@ -315,19 +297,13 @@ 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>
@@ -344,17 +320,13 @@ 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()}
@@ -371,9 +343,7 @@ 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: "" })}{" "}
@@ -383,9 +353,7 @@ 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">
@@ -4,16 +4,8 @@ import type {
ConnectionStatus,
NewConnection,
} from "@app/core/stores/deviceStore/types";
import {
createConnectionFromInput,
testHttpReachable,
} from "@app/pages/Connections/utils";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { createConnectionFromInput, testHttpReachable } from "@app/pages/Connections/utils";
import { useAppStore, useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
@@ -35,9 +27,7 @@ export function useConnections() {
const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
const removeSavedConnectionFromStore = useDeviceStore(
(s) => s.removeSavedConnection,
);
const removeSavedConnectionFromStore = useDeviceStore((s) => s.removeSavedConnection);
// DeviceStore methods
const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId);
@@ -77,9 +67,7 @@ export function useConnections() {
if (unsubConfigComplete) {
unsubConfigComplete();
configSubscriptions.delete(id);
console.log(
`[useConnections] Config subscription cleaned up for connection ${id}`,
);
console.log(`[useConnections] Config subscription cleaned up for connection ${id}`);
}
// Get device and MeshDevice from Device.connection
@@ -178,9 +166,7 @@ export function useConnections() {
// Listen for config complete event (with nonce/ID)
const unsubConfigComplete = meshDevice.events.onConfigComplete.subscribe(
(configCompleteId) => {
console.log(
`[useConnections] Configuration complete with ID: ${configCompleteId}`,
);
console.log(`[useConnections] Configuration complete with ID: ${configCompleteId}`);
device.setConnectionPhase("configured");
updateStatus(id, "configured");
@@ -188,9 +174,7 @@ export function useConnections() {
const oldHeartbeat = heartbeats.get(id);
if (oldHeartbeat) {
clearInterval(oldHeartbeat);
console.log(
`[useConnections] Switching to maintenance heartbeat (5 min interval)`,
);
console.log(`[useConnections] Switching to maintenance heartbeat (5 min interval)`);
}
const maintenanceHeartbeat = setInterval(() => {
@@ -211,9 +195,7 @@ export function useConnections() {
meshDevice
.configure()
.then(() => {
console.log(
"[useConnections] Configuration complete, starting heartbeat",
);
console.log("[useConnections] Configuration complete, starting heartbeat");
// Send initial heartbeat after configure completes
meshDevice
.heartbeat()
@@ -221,10 +203,7 @@ export function useConnections() {
// Start fast heartbeat after first successful heartbeat
const configHeartbeatId = setInterval(() => {
meshDevice.heartbeat().catch((error) => {
console.warn(
"[useConnections] Config heartbeat failed:",
error,
);
console.warn("[useConnections] Config heartbeat failed:", error);
});
}, CONFIG_HEARTBEAT_INTERVAL_MS);
heartbeats.set(id, configHeartbeatId);
@@ -303,9 +282,7 @@ export function useConnections() {
if (getDevices) {
const known = await getDevices();
if (known && known.length > 0 && conn.deviceId) {
bleDevice = known.find(
(d: BluetoothDevice) => d.id === conn.deviceId,
);
bleDevice = known.find((d: BluetoothDevice) => d.id === conn.deviceId);
}
}
}
@@ -313,22 +290,15 @@ export function useConnections() {
// Prompt user to reselect (filter by optional service if provided)
bleDevice = await navigator.bluetooth.requestDevice({
acceptAllDevices: !conn.gattServiceUUID,
optionalServices: conn.gattServiceUUID
? [conn.gattServiceUUID]
: undefined,
filters: conn.gattServiceUUID
? [{ services: [conn.gattServiceUUID] }]
: undefined,
optionalServices: conn.gattServiceUUID ? [conn.gattServiceUUID] : undefined,
filters: conn.gattServiceUUID ? [{ services: [conn.gattServiceUUID] }] : undefined,
});
}
if (!bleDevice) {
throw new Error(
"Bluetooth device not available. Re-select the device.",
);
throw new Error("Bluetooth device not available. Re-select the device.");
}
const transport =
await TransportWebBluetooth.createFromDevice(bleDevice);
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
setupMeshDevice(id, transport, bleDevice);
bleDevice.addEventListener("gattserverdisconnected", () => {
@@ -363,8 +333,7 @@ export function useConnections() {
}
).getInfo?.() ?? {};
return (
info.usbVendorId === conn.usbVendorId &&
info.usbProductId === conn.usbProductId
info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId
);
});
}
@@ -373,9 +342,7 @@ export function useConnections() {
port = await (
navigator as Navigator & {
serial: {
requestPort: (
options: Record<string, unknown>,
) => Promise<SerialPort>;
requestPort: (options: Record<string, unknown>) => Promise<SerialPort>;
};
}
).serial.requestPort({});
@@ -426,9 +393,7 @@ export function useConnections() {
if (heartbeatId) {
clearInterval(heartbeatId);
heartbeats.delete(id);
console.log(
`[useConnections] Heartbeat stopped for connection ${id}`,
);
console.log(`[useConnections] Heartbeat stopped for connection ${id}`);
}
// Unsubscribe from config complete event
@@ -436,9 +401,7 @@ export function useConnections() {
if (unsubConfigComplete) {
unsubConfigComplete();
configSubscriptions.delete(id);
console.log(
`[useConnections] Config subscription cleaned up for connection ${id}`,
);
console.log(`[useConnections] Config subscription cleaned up for connection ${id}`);
}
// Get device and meshDevice from Device.connection
@@ -560,9 +523,7 @@ export function useConnections() {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices?.();
const hasPermission = known?.some(
(d: BluetoothDevice) => d.id === c.deviceId,
);
const hasPermission = known?.some((d: BluetoothDevice) => d.id === c.deviceId);
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
});
@@ -607,10 +568,7 @@ export function useConnections() {
};
}
).getInfo?.() ?? {};
return (
info.usbVendorId === c.usbVendorId &&
info.usbProductId === c.usbProductId
);
return info.usbVendorId === c.usbVendorId && info.usbProductId === c.usbProductId;
});
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
@@ -626,9 +584,7 @@ export function useConnections() {
const syncConnectionStatuses = useCallback(() => {
// Find which connection corresponds to the currently selected device
const activeConnection = connections.find(
(c) => c.meshDeviceId === selectedDeviceId,
);
const activeConnection = connections.find((c) => c.meshDeviceId === selectedDeviceId);
// Update all connection statuses
connections.forEach((conn) => {
+1 -4
View File
@@ -40,10 +40,7 @@ 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);
+10 -49
View File
@@ -4,10 +4,7 @@ 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,
@@ -31,14 +28,7 @@ import { hasPos, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
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";
@@ -50,9 +40,7 @@ const MapPage = () => {
const { nodes: validNodes, myNode } = useNodeDB(
(db) => ({
// only nodes with a position
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo =>
Boolean(n.position?.latitudeI),
),
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI)),
myNode: db.getMyNode(),
// References to cause re-render on change
@@ -76,9 +64,7 @@ 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(
@@ -119,12 +105,7 @@ const MapPage = () => {
mode={heatmapMode}
/>
),
[
filteredNodes,
visibilityState.heatmap,
heatmapMode,
heatmapLayerElementId,
],
[filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId],
);
const onMouseMove = useCallback(
@@ -136,8 +117,7 @@ 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 (
@@ -197,14 +177,7 @@ const MapPage = () => {
isVisible={visibilityState.nodeMarkers}
/>
),
[
filteredNodes,
expandedCluster,
mapRef,
myNode,
popupState,
visibilityState.nodeMarkers,
],
[filteredNodes, expandedCluster, mapRef, myNode, popupState, visibilityState.nodeMarkers],
);
// Precision circles
@@ -217,11 +190,7 @@ const MapPage = () => {
isVisible={visibilityState.positionPrecision}
/>
),
[
filteredNodes,
visibilityState.positionPrecision,
precisionCirclesElementId,
],
[filteredNodes, visibilityState.positionPrecision, precisionCirclesElementId],
);
// Waypoints
@@ -244,10 +213,7 @@ const MapPage = () => {
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
interactiveLayerIds={[
snrLayerElementId,
`${heatmapLayerElementId}-interaction`,
]}
interactiveLayerIds={[snrLayerElementId, `${heatmapLayerElementId}-interaction`]}
>
{heatmapLayerElement}
{markerElements}
@@ -256,12 +222,7 @@ 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">
+12 -47
View File
@@ -21,13 +21,7 @@ import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
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";
@@ -65,8 +59,7 @@ 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 allChannels = Array.from(channels.values());
@@ -77,10 +70,7 @@ export const MessagesPage = () => {
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]);
@@ -96,10 +86,7 @@ export const MessagesPage = () => {
return getNodes((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);
}, true)
.map((node: Protobuf.Mesh.NodeInfo) => ({
...node,
@@ -117,19 +104,12 @@ export const MessagesPage = () => {
const sendText = useCallback(
async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: numericChatId;
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
let messageId: number | undefined;
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
messageId = await connection?.sendText(message, toValue, true, channelValue);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
@@ -217,19 +197,13 @@ 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());
resetUnread(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>
@@ -248,10 +222,7 @@ 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"
@@ -263,9 +234,7 @@ 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) => (
@@ -274,9 +243,7 @@ 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());
resetUnread(node.num);
@@ -314,9 +281,7 @@ 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",
+13 -38
View File
@@ -1,16 +1,9 @@
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";
@@ -21,13 +14,7 @@ import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import {
type JSX,
useCallback,
useDeferredValue,
useEffect,
useState,
} from "react";
import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { base16 } from "rfc4648";
@@ -53,9 +40,7 @@ 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
@@ -136,9 +121,7 @@ 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", {
@@ -164,7 +147,9 @@ const NodesPage = (): JSX.Element => {
<h1
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
if (evt.key === "Enter") {
handleNodeInfoDialog(node.num);
}
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
>
@@ -180,14 +165,10 @@ 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.hops_one")
(node.hopsAway ?? 0 > 1) ? t("unit.hop.plural") : t("unit.hops_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,
@@ -198,10 +179,7 @@ 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>
),
@@ -232,11 +210,8 @@ const NodesPage = (): JSX.Element => {
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,12 +6,7 @@ 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 { useDevice, type ValidConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
@@ -31,52 +26,53 @@ type TabItem = {
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { hasConfigChange, hasUserChange } = useDevice();
const { t } = useTranslation("config");
const tabs: TabItem[] = [
{
case: "user",
label: t("page.tabUser"),
element: User,
},
{
case: "device",
label: t("page.tabDevice"),
element: Device,
},
{
case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
case: "power",
label: t("page.tabPower"),
element: Power,
},
{
case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
] as const;
const tabs: TabItem[] = useMemo(
() => [
{
case: "user",
label: t("page.tabUser"),
element: User,
},
{
case: "device",
label: t("page.tabDevice"),
element: Device,
},
{
case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
case: "power",
label: t("page.tabPower"),
element: Power,
},
{
case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
],
[t],
);
const flags = useMemo(
() =>
new Map(
tabs.map((tab) => [
tab.case,
tab.case === "user"
? hasUserChange()
: hasConfigChange(tab.case as ValidConfigType),
tab.case === "user" ? hasUserChange() : hasConfigChange(tab.case as ValidConfigType),
]),
),
[tabs, hasConfigChange, hasUserChange],
@@ -86,11 +82,7 @@ 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">
@@ -11,12 +11,7 @@ import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.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 { useDevice, type ValidModuleConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
@@ -36,68 +31,70 @@ type TabItem = {
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { hasModuleConfigChange } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs: TabItem[] = [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
] as const;
const tabs: TabItem[] = useMemo(
() => [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
],
[t],
);
const flags = useMemo(
() =>
new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
() => new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])),
[tabs, hasModuleConfigChange],
);
@@ -105,11 +102,7 @@ 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">
+25 -30
View File
@@ -2,12 +2,7 @@ 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 { useDevice, type ValidConfigType } from "@core/stores";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
@@ -18,7 +13,7 @@ interface ConfigProps {
}
type TabItem = {
case: ValidConfigType;
case: ValidConfigType | "channels";
label: string;
element: ComponentType<ConfigProps>;
count?: number;
@@ -27,26 +22,30 @@ type TabItem = {
export const RadioConfig = ({ onFormInit }: ConfigProps) => {
const { hasConfigChange } = useDevice();
const { t } = useTranslation("config");
const tabs: TabItem[] = [
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
case: "channels",
label: t("page.tabChannels"),
element: Channels,
},
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
] as const;
const tabs: TabItem[] = useMemo(
() => [
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
case: "channels",
label: t("page.tabChannels"),
element: Channels,
},
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
],
[t],
);
const flags = useMemo(
() => new Map(tabs.map((tab) => [tab.case, hasConfigChange(tab.case)])),
() =>
new Map(tabs.map((tab) => [tab.case, tab.case !== "channels" && hasConfigChange(tab.case)])),
[tabs, hasConfigChange],
);
@@ -54,11 +53,7 @@ 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">
+21 -26
View File
@@ -90,30 +90,27 @@ 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?.();
@@ -206,7 +203,7 @@ const ConfigPage = () => {
formMethods.trigger();
}
} catch (_error) {
} catch {
toast({
title: t("toast.configSaveError.title"),
description: t("toast.configSaveError.description"),
@@ -300,9 +297,7 @@ 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",