Improvements to node filtering and storage (#839)
* Improves node filtering and voltage display Ensures voltage values are always displayed as positive. Enhances node filtering logic to handle unknown or undefined values, preventing nodes from being unexpectedly hidden. Updates UI labels for clarity. Improves Nodes page responsiveness by debouncing node updates and optimizing selector usage, preventing unnecessary re-renders. Adds validation warnings for key conflicts between nodes. * Update packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/core/stores/nodeDBStore/nodeValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert copilot suggestion * Review changes --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
philon-
parent
52fd2f712c
commit
3e2fe721d3
@@ -46,11 +46,7 @@
|
|||||||
"connectionStatus": {
|
"connectionStatus": {
|
||||||
"direct": "Direct",
|
"direct": "Direct",
|
||||||
"away": "away",
|
"away": "away",
|
||||||
"unknown": "-",
|
|
||||||
"viaMqtt": ", via MQTT"
|
"viaMqtt": ", via MQTT"
|
||||||
},
|
|
||||||
"lastHeardStatus": {
|
|
||||||
"never": "Never"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
"label": "Unknown number of hops"
|
"label": "Unknown number of hops"
|
||||||
},
|
},
|
||||||
"showUnheard": {
|
"showUnheard": {
|
||||||
"label": "Never heard"
|
"label": "Unknown last heard"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ export const NodeDetailsDialog = ({
|
|||||||
{
|
{
|
||||||
key: "voltage",
|
key: "voltage",
|
||||||
label: t("nodeDetails.voltage"),
|
label: t("nodeDetails.voltage"),
|
||||||
value: node.deviceMetrics?.voltage,
|
value:
|
||||||
|
typeof node.deviceMetrics?.voltage === "number"
|
||||||
|
? Math.abs(node.deviceMetrics?.voltage)
|
||||||
|
: undefined,
|
||||||
format: (val: number) => `${val.toFixed(2)}V`,
|
format: (val: number) => `${val.toFixed(2)}V`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -205,7 +205,10 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
}
|
}
|
||||||
deviceMetrics={{
|
deviceMetrics={{
|
||||||
batteryLevel: myNode.deviceMetrics?.batteryLevel,
|
batteryLevel: myNode.deviceMetrics?.batteryLevel,
|
||||||
voltage: myNode.deviceMetrics?.voltage,
|
voltage:
|
||||||
|
typeof myNode.deviceMetrics?.voltage === "number"
|
||||||
|
? Math.abs(myNode.deviceMetrics?.voltage)
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function Slider({
|
|||||||
onValueCommit?.(newValue);
|
onValueCommit?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const thumbIds = currentValue.map((_, idx) => `${internalId}-thumb-${idx}`); // Unique IDs for each thumb, pregenerated to please the linter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -79,14 +81,14 @@ export function Slider({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
{currentValue.map((_) => (
|
{currentValue.map((_, idx) => (
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
key={`${internalId}-thumb`}
|
key={thumbIds[idx]}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
|
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
|
||||||
thumbClassName,
|
thumbClassName,
|
||||||
)}
|
)}
|
||||||
aria-label={`Thumb ${internalId}`}
|
aria-label={`Thumb ${idx + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|||||||
@@ -67,25 +67,33 @@ export function useFilterNode() {
|
|||||||
...filterOverrides,
|
...filterOverrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!node.user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeName = filterState.nodeName.toLowerCase();
|
const nodeName = filterState.nodeName.toLowerCase();
|
||||||
if (
|
if (nodeName) {
|
||||||
nodeName &&
|
const short = node.user?.shortName?.toLowerCase() ?? "";
|
||||||
!(
|
const long = node.user?.longName?.toLowerCase() ?? "";
|
||||||
node.user?.shortName.toLowerCase().includes(nodeName) ||
|
const numStr = node.num.toString();
|
||||||
node.user?.longName.toLowerCase().includes(nodeName) ||
|
const hex = numberToHexUnpadded(node.num);
|
||||||
node.num.toString().includes(nodeName) ||
|
|
||||||
numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, ""))
|
if (
|
||||||
)
|
!short.includes(nodeName) &&
|
||||||
) {
|
!long.includes(nodeName) &&
|
||||||
return false;
|
!numStr.includes(nodeName) &&
|
||||||
|
!hex.includes(nodeName.replace(/!/g, ""))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hops = node.hopsAway ?? 7;
|
const hops = node.hopsAway ?? 7;
|
||||||
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) {
|
if (
|
||||||
|
(node.hopsAway === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.hopsAway,
|
||||||
|
defaultFilterValues.hopsAway,
|
||||||
|
)) || // If hops are unknown, hide node if state is not default
|
||||||
|
hops < filterState.hopsAway[0] ||
|
||||||
|
hops > filterState.hopsAway[1]
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +104,13 @@ export function useFilterNode() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0);
|
const secondsAgo = Math.max(0, Date.now() / 1000 - (node.lastHeard ?? 0));
|
||||||
if (
|
if (
|
||||||
|
(node.lastHeard === 0 &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.lastHeard,
|
||||||
|
defaultFilterValues.lastHeard,
|
||||||
|
)) || // If lastHeard is unknown (0), hide node if state is not default
|
||||||
secondsAgo < filterState.lastHeard[0] ||
|
secondsAgo < filterState.lastHeard[0] ||
|
||||||
(secondsAgo > filterState.lastHeard[1] &&
|
(secondsAgo > filterState.lastHeard[1] &&
|
||||||
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1])
|
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1])
|
||||||
@@ -128,6 +141,8 @@ export function useFilterNode() {
|
|||||||
|
|
||||||
const snr = node.snr ?? -20;
|
const snr = node.snr ?? -20;
|
||||||
if (
|
if (
|
||||||
|
(node.snr === undefined &&
|
||||||
|
!shallowEqualArray(filterState.snr, defaultFilterValues.snr)) ||
|
||||||
(snr < filterState.snr[0] &&
|
(snr < filterState.snr[0] &&
|
||||||
filterState.snr[0] !== defaultFilterValues.snr[0]) ||
|
filterState.snr[0] !== defaultFilterValues.snr[0]) ||
|
||||||
(snr > filterState.snr[1] &&
|
(snr > filterState.snr[1] &&
|
||||||
@@ -138,6 +153,11 @@ export function useFilterNode() {
|
|||||||
|
|
||||||
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
|
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
|
||||||
if (
|
if (
|
||||||
|
(node.deviceMetrics?.channelUtilization === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.channelUtilization,
|
||||||
|
defaultFilterValues.channelUtilization,
|
||||||
|
)) ||
|
||||||
channelUtilization < filterState.channelUtilization[0] ||
|
channelUtilization < filterState.channelUtilization[0] ||
|
||||||
channelUtilization > filterState.channelUtilization[1]
|
channelUtilization > filterState.channelUtilization[1]
|
||||||
) {
|
) {
|
||||||
@@ -146,6 +166,11 @@ export function useFilterNode() {
|
|||||||
|
|
||||||
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
|
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
|
||||||
if (
|
if (
|
||||||
|
(node.deviceMetrics?.airUtilTx === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.airUtilTx,
|
||||||
|
defaultFilterValues.airUtilTx,
|
||||||
|
)) ||
|
||||||
airUtilTx < filterState.airUtilTx[0] ||
|
airUtilTx < filterState.airUtilTx[0] ||
|
||||||
airUtilTx > filterState.airUtilTx[1]
|
airUtilTx > filterState.airUtilTx[1]
|
||||||
) {
|
) {
|
||||||
@@ -154,14 +179,24 @@ export function useFilterNode() {
|
|||||||
|
|
||||||
const batt = node.deviceMetrics?.batteryLevel ?? 101;
|
const batt = node.deviceMetrics?.batteryLevel ?? 101;
|
||||||
if (
|
if (
|
||||||
|
(node.deviceMetrics?.batteryLevel === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.batteryLevel,
|
||||||
|
defaultFilterValues.batteryLevel,
|
||||||
|
)) ||
|
||||||
batt < filterState.batteryLevel[0] ||
|
batt < filterState.batteryLevel[0] ||
|
||||||
batt > filterState.batteryLevel[1]
|
batt > filterState.batteryLevel[1]
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voltage = node.deviceMetrics?.voltage ?? 0;
|
const voltage = Math.abs(node.deviceMetrics?.voltage ?? 0);
|
||||||
if (
|
if (
|
||||||
|
(node.deviceMetrics?.voltage === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.voltage,
|
||||||
|
defaultFilterValues.voltage,
|
||||||
|
)) ||
|
||||||
voltage < filterState.voltage[0] ||
|
voltage < filterState.voltage[0] ||
|
||||||
(voltage > filterState.voltage[1] &&
|
(voltage > filterState.voltage[1] &&
|
||||||
filterState.voltage[1] !== defaultFilterValues.voltage[1])
|
filterState.voltage[1] !== defaultFilterValues.voltage[1])
|
||||||
@@ -170,14 +205,25 @@ export function useFilterNode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role: Protobuf.Config.Config_DeviceConfig_Role =
|
const role: Protobuf.Config.Config_DeviceConfig_Role =
|
||||||
node.user.role ?? Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
|
node.user?.role ?? Protobuf.Config.Config_DeviceConfig_Role.CLIENT;
|
||||||
if (!filterState.role.includes(role)) {
|
if (
|
||||||
|
(node.user?.role === undefined &&
|
||||||
|
!shallowEqualArray(filterState.role, defaultFilterValues.role)) ||
|
||||||
|
!filterState.role.includes(role)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwModel: Protobuf.Mesh.HardwareModel =
|
const hwModel: Protobuf.Mesh.HardwareModel =
|
||||||
node.user.hwModel ?? Protobuf.Mesh.HardwareModel.UNSET;
|
node.user?.hwModel ?? Protobuf.Mesh.HardwareModel.UNSET;
|
||||||
if (!filterState.hwModel.includes(hwModel)) {
|
if (
|
||||||
|
(node.user?.hwModel === undefined &&
|
||||||
|
!shallowEqualArray(
|
||||||
|
filterState.hwModel,
|
||||||
|
defaultFilterValues.hwModel,
|
||||||
|
)) ||
|
||||||
|
!filterState.hwModel.includes(hwModel)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
|||||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
||||||
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
|
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
|
||||||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
||||||
|
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CurrentDeviceContext,
|
CurrentDeviceContext,
|
||||||
@@ -30,13 +31,11 @@ export {
|
|||||||
} from "@core/stores/sidebarStore";
|
} from "@core/stores/sidebarStore";
|
||||||
|
|
||||||
// Define hooks to access the stores
|
// Define hooks to access the stores
|
||||||
export const useNodeDB = (): NodeDB => {
|
export const useNodeDB = bindStoreToDevice(
|
||||||
const { deviceId } = useDeviceContext();
|
useNodeDBStore,
|
||||||
const nodeDB = useNodeDBStore(
|
(s, deviceId): NodeDB => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId),
|
||||||
(s) => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId),
|
);
|
||||||
);
|
|
||||||
return nodeDB;
|
|
||||||
};
|
|
||||||
export const useDevice = (): Device => {
|
export const useDevice = (): Device => {
|
||||||
const { deviceId } = useDeviceContext();
|
const { deviceId } = useDeviceContext();
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ export const useDevice = (): Device => {
|
|||||||
);
|
);
|
||||||
return device;
|
return device;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMessages = (): MessageStore => {
|
export const useMessages = (): MessageStore => {
|
||||||
const { deviceId } = useDeviceContext();
|
const { deviceId } = useDeviceContext();
|
||||||
|
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ const persistOptions: PersistOptions<
|
|||||||
PrivateMessageStoreState,
|
PrivateMessageStoreState,
|
||||||
MessageStorePersisted
|
MessageStorePersisted
|
||||||
> = {
|
> = {
|
||||||
name: "meshtastic-MessageStore-store",
|
name: "meshtastic-message-store",
|
||||||
storage: createStorage<MessageStorePersisted>(),
|
storage: createStorage<MessageStorePersisted>(),
|
||||||
version: CURRENT_STORE_VERSION,
|
version: CURRENT_STORE_VERSION,
|
||||||
partialize: (s): MessageStorePersisted => ({
|
partialize: (s): MessageStorePersisted => ({
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { createStorage } from "@core/stores/utils/indexDB.ts";
|
|||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { create as createStore, type StateCreator } from "zustand";
|
import { create as createStore, type StateCreator } from "zustand";
|
||||||
import { type PersistOptions, persist } from "zustand/middleware";
|
import {
|
||||||
|
type PersistOptions,
|
||||||
|
persist,
|
||||||
|
subscribeWithSelector,
|
||||||
|
} from "zustand/middleware";
|
||||||
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
||||||
|
|
||||||
const CURRENT_STORE_VERSION = 0;
|
const CURRENT_STORE_VERSION = 0;
|
||||||
@@ -102,7 +106,8 @@ function nodeDBFactory(
|
|||||||
// Validation failed and error has been set inside validateIncomingNode
|
// Validation failed and error has been set inside validateIncomingNode
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nodeDB.nodeMap.set(node.num, next);
|
|
||||||
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(node.num, next);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -113,7 +118,9 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
nodeDB.nodeMap.delete(nodeNum);
|
const updated = new Map(nodeDB.nodeMap);
|
||||||
|
updated.delete(nodeNum);
|
||||||
|
nodeDB.nodeMap = updated;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -147,7 +154,10 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
nodeDB.nodeErrors.set(nodeNum, { node: nodeNum, error });
|
nodeDB.nodeErrors = new Map(nodeDB.nodeErrors).set(nodeNum, {
|
||||||
|
node: nodeNum,
|
||||||
|
error,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -158,7 +168,9 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
nodeDB.nodeErrors.delete(nodeNum);
|
const updated = new Map(nodeDB.nodeErrors);
|
||||||
|
updated.delete(nodeNum);
|
||||||
|
nodeDB.nodeErrors = updated;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -181,16 +193,21 @@ function nodeDBFactory(
|
|||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
const node = nodeDB.nodeMap.get(data.from);
|
const node = nodeDB.nodeMap.get(data.from);
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000); // lastHeard is in seconds(!)
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
node.lastHeard = data.time > 0 ? data.time : Date.now(); // fallback to now if time is 0 or negative
|
const updated = {
|
||||||
node.snr = data.snr;
|
...node,
|
||||||
nodeDB.nodeMap.set(data.from, node);
|
lastHeard: data.time > 0 ? data.time : nowSec,
|
||||||
|
snr: data.snr,
|
||||||
|
};
|
||||||
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(data.from, updated);
|
||||||
} else {
|
} else {
|
||||||
nodeDB.nodeMap.set(
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(
|
||||||
data.from,
|
data.from,
|
||||||
create(Protobuf.Mesh.NodeInfoSchema, {
|
create(Protobuf.Mesh.NodeInfoSchema, {
|
||||||
num: data.from,
|
num: data.from,
|
||||||
lastHeard: data.time > 0 ? data.time : Date.now(), // fallback to now if time is 0 or negative,
|
lastHeard: data.time > 0 ? data.time : nowSec, // fallback to now if time is 0 or negative,
|
||||||
snr: data.snr,
|
snr: data.snr,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -208,9 +225,8 @@ function nodeDBFactory(
|
|||||||
const current =
|
const current =
|
||||||
nodeDB.nodeMap.get(user.from) ??
|
nodeDB.nodeMap.get(user.from) ??
|
||||||
create(Protobuf.Mesh.NodeInfoSchema);
|
create(Protobuf.Mesh.NodeInfoSchema);
|
||||||
current.user = user.data;
|
const updated = { ...current, user: user.data, num: user.from };
|
||||||
current.num = user.from;
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
|
||||||
nodeDB.nodeMap.set(user.from, current);
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -224,9 +240,12 @@ function nodeDBFactory(
|
|||||||
const current =
|
const current =
|
||||||
nodeDB.nodeMap.get(position.from) ??
|
nodeDB.nodeMap.get(position.from) ??
|
||||||
create(Protobuf.Mesh.NodeInfoSchema);
|
create(Protobuf.Mesh.NodeInfoSchema);
|
||||||
current.position = position.data;
|
const updated = {
|
||||||
current.num = position.from;
|
...current,
|
||||||
nodeDB.nodeMap.set(position.from, current);
|
position: position.data,
|
||||||
|
num: position.from,
|
||||||
|
};
|
||||||
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -259,22 +278,25 @@ function nodeDBFactory(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
|
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
|
||||||
mergedErrors.set(nodeNum, { error: err } as NodeError);
|
mergedErrors.set(nodeNum, {
|
||||||
|
node: nodeNum,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [nodeNum, newNode] of newDB.nodeMap) {
|
for (const [num, newNode] of newDB.nodeMap) {
|
||||||
const next = validateIncomingNode(
|
const next = validateIncomingNode(
|
||||||
newNode,
|
newNode,
|
||||||
setErrorProxy,
|
setErrorProxy,
|
||||||
getNodesProxy,
|
getNodesProxy,
|
||||||
);
|
);
|
||||||
if (next) {
|
if (next) {
|
||||||
mergedNodes.set(nodeNum, next);
|
mergedNodes.set(num, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const err = newDB.getNodeError(nodeNum);
|
const err = newDB.getNodeError(num);
|
||||||
if (err && !oldDB.hasNodeError(nodeNum)) {
|
if (err && !oldDB.hasNodeError(num)) {
|
||||||
mergedErrors.set(nodeNum, err);
|
mergedErrors.set(num, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +319,10 @@ function nodeDBFactory(
|
|||||||
|
|
||||||
const node = nodeDB.nodeMap.get(nodeNum);
|
const node = nodeDB.nodeMap.get(nodeNum);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.isFavorite = isFavorite;
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
|
||||||
|
...node,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -312,7 +337,10 @@ function nodeDBFactory(
|
|||||||
|
|
||||||
const node = nodeDB.nodeMap.get(nodeNum);
|
const node = nodeDB.nodeMap.get(nodeNum);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.isIgnored = isIgnored;
|
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
|
||||||
|
...node,
|
||||||
|
isIgnored: isIgnored,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -392,7 +420,7 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
|||||||
const nodeDB = nodeDBFactory(id, get, set);
|
const nodeDB = nodeDBFactory(id, get, set);
|
||||||
set(
|
set(
|
||||||
produce<PrivateNodeDBState>((draft) => {
|
produce<PrivateNodeDBState>((draft) => {
|
||||||
draft.nodeDBs.set(id, nodeDB);
|
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
|
||||||
|
|
||||||
// Enforce retention limit
|
// Enforce retention limit
|
||||||
evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM);
|
evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM);
|
||||||
@@ -404,7 +432,9 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
|||||||
removeNodeDB: (id) => {
|
removeNodeDB: (id) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateNodeDBState>((draft) => {
|
produce<PrivateNodeDBState>((draft) => {
|
||||||
draft.nodeDBs.delete(id);
|
const updated = new Map(draft.nodeDBs);
|
||||||
|
updated.delete(id);
|
||||||
|
draft.nodeDBs = updated;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -472,7 +502,7 @@ console.debug(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const useNodeDBStore = persistNodes
|
export const useNodeDBStore = persistNodes
|
||||||
? createStore<PrivateNodeDBState, [["zustand/persist", NodeDBPersisted]]>(
|
? createStore(
|
||||||
persist(nodeDBInitializer, persistOptions),
|
subscribeWithSelector(persist(nodeDBInitializer, persistOptions)),
|
||||||
)
|
)
|
||||||
: createStore<PrivateNodeDBState>()(nodeDBInitializer);
|
: createStore(subscribeWithSelector(nodeDBInitializer));
|
||||||
|
|||||||
+130
-7
@@ -1,7 +1,6 @@
|
|||||||
/** biome-ignore-all lint/suspicious/noExplicitAny: <tests> */
|
|
||||||
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
|
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { toByteArray } from "base64-js";
|
import { toByteArray } from "base64-js";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -18,6 +17,14 @@ vi.mock("idb-keyval", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let deviceIdForTests = 1;
|
||||||
|
vi.mock("@core/hooks/useDeviceContext", () => ({
|
||||||
|
useDeviceContext: () => ({ deviceId: deviceIdForTests }),
|
||||||
|
__setDeviceId: (id: number) => {
|
||||||
|
deviceIdForTests = id;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// import a fresh copy of the store module (because the store is created at import time)
|
// import a fresh copy of the store module (because the store is created at import time)
|
||||||
async function freshStore(persist = false) {
|
async function freshStore(persist = false) {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -33,8 +40,9 @@ async function freshStore(persist = false) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mod = await import("./index.ts");
|
const storeMod = await import("./index.ts");
|
||||||
return mod;
|
const { useNodeDB } = await import("../index.ts");
|
||||||
|
return { ...storeMod, useNodeDB };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNode(num: number, extras: Record<string, any> = {}) {
|
function makeNode(num: number, extras: Record<string, any> = {}) {
|
||||||
@@ -97,7 +105,7 @@ describe("NodeDB store", () => {
|
|||||||
expect(db.getNode(50)?.snr).toBe(9);
|
expect(db.getNode(50)?.snr).toBe(9);
|
||||||
|
|
||||||
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
|
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
|
||||||
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now(), -1); // within 10ms
|
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now() / 1000, -1); // within 1s, note lastHeard is in seconds
|
||||||
expect(db.getNode(50)?.snr).toBe(9);
|
expect(db.getNode(50)?.snr).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,9 +344,9 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
|||||||
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
|
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
|
||||||
expect(n5.user?.longName).toBe("old-5");
|
expect(n5.user?.longName).toBe("old-5");
|
||||||
|
|
||||||
// error flagged
|
// error not flagged; dropped silently
|
||||||
const err = newDB!.getNodeError(5);
|
const err = newDB!.getNodeError(5);
|
||||||
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
|
expect(err).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("old key empty, new key present, store new node", async () => {
|
it("old key empty, new key present, store new node", async () => {
|
||||||
@@ -451,3 +459,118 @@ describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
|||||||
expect(newDB.getMyNode().num).toBe(4242);
|
expect(newDB.getMyNode().num).toBe(4242);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NodeDB deviceContext & debounce", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
idbMem.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useNodeDB resolves per-device DB and switches with deviceId", async () => {
|
||||||
|
const { useNodeDBStore, useNodeDB } = await freshStore();
|
||||||
|
|
||||||
|
// device 1
|
||||||
|
deviceIdForTests = 1;
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
const db1 = st.addNodeDB(1);
|
||||||
|
db1.addNode({ num: 10 } as any);
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
const len = useNodeDB((db) => db.getNodesLength(), {
|
||||||
|
debounce: 0,
|
||||||
|
equality: (a, b) => a === b,
|
||||||
|
});
|
||||||
|
return <div data-testid="len">{len}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<Comp />);
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("1");
|
||||||
|
|
||||||
|
// switch to device 2 and add nodes
|
||||||
|
deviceIdForTests = 2;
|
||||||
|
const db2 = st.addNodeDB(2);
|
||||||
|
db2.addNode({ num: 20 } as any);
|
||||||
|
db2.addNode({ num: 21 } as any);
|
||||||
|
db2.addNode({ num: 22 } as any);
|
||||||
|
|
||||||
|
// re-render so the hook re-subscribes with the new deviceId
|
||||||
|
await act(async () => {
|
||||||
|
rerender(<Comp />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useNodeDB selector re-renders only when the selected slice changes", async () => {
|
||||||
|
const { useNodeDBStore, useNodeDB } = await freshStore();
|
||||||
|
deviceIdForTests = 1;
|
||||||
|
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
const db = st.addNodeDB(1);
|
||||||
|
|
||||||
|
let renders = 0;
|
||||||
|
function Comp() {
|
||||||
|
const len = useNodeDB((d) => d.getNodesLength(), {
|
||||||
|
debounce: 0,
|
||||||
|
equality: (a, b) => a === b,
|
||||||
|
});
|
||||||
|
renders++;
|
||||||
|
return <div data-testid="len">{len}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("0");
|
||||||
|
expect(renders).toBe(1);
|
||||||
|
|
||||||
|
// mutate something unrelated to length
|
||||||
|
db.setNodeError(999, "X" as any);
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("0");
|
||||||
|
expect(renders).toBe(1); // no re-render
|
||||||
|
|
||||||
|
// now actually change the slice
|
||||||
|
db.addNode({ num: 1 } as any);
|
||||||
|
await act(() => Promise.resolve());
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("1");
|
||||||
|
expect(renders).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useNodeDB debounce coalesces rapid updates", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { useNodeDBStore, useNodeDB } = await freshStore();
|
||||||
|
deviceIdForTests = 1;
|
||||||
|
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
const db = st.addNodeDB(1);
|
||||||
|
|
||||||
|
let renders = 0;
|
||||||
|
function Comp() {
|
||||||
|
const len = useNodeDB((d) => d.getNodesLength(), {
|
||||||
|
debounce: 50,
|
||||||
|
equality: (a, b) => a === b,
|
||||||
|
});
|
||||||
|
renders++;
|
||||||
|
return <div data-testid="len">{len}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
// burst of updates within the debounce window
|
||||||
|
db.addNode({ num: 1 } as any);
|
||||||
|
db.addNode({ num: 2 } as any);
|
||||||
|
db.addNode({ num: 3 } as any);
|
||||||
|
|
||||||
|
await act(() => {
|
||||||
|
vi.advanceTimersByTime(49);
|
||||||
|
});
|
||||||
|
expect(renders).toBe(1); // not yet
|
||||||
|
|
||||||
|
await act(() => {
|
||||||
|
vi.advanceTimersByTime(2);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("len").textContent).toBe("3");
|
||||||
|
expect(renders).toBe(2); // single coalesced re-render
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
import type { NodeErrorType } from "@core/stores";
|
import type { NodeErrorType } from "@core/stores";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
import { fromByteArray } from "base64-js";
|
||||||
|
|
||||||
|
export function equalKey(
|
||||||
|
a?: Uint8Array | null,
|
||||||
|
b?: Uint8Array | null,
|
||||||
|
): boolean {
|
||||||
|
if (!a || !b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const len = a.byteLength;
|
||||||
|
if (len !== b.byteLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Validates a new incoming node against existing nodes.
|
// Validates a new incoming node against existing nodes.
|
||||||
// If valid, returns a node to store, else returns undefined.
|
// If valid, returns a node to store, else returns undefined.
|
||||||
@@ -26,6 +49,12 @@ export function validateIncomingNode(
|
|||||||
);
|
);
|
||||||
if (nodesWithSameKey.length > 0) {
|
if (nodesWithSameKey.length > 0) {
|
||||||
// This is a potential impersonation attempt.
|
// This is a potential impersonation attempt.
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Node ${num} rejected: Public key already claimed by another node. Key:`,
|
||||||
|
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
|
||||||
|
);
|
||||||
|
|
||||||
setNodeError(num, "DUPLICATE_PKI");
|
setNodeError(num, "DUPLICATE_PKI");
|
||||||
return undefined; // drop newNode entirely
|
return undefined; // drop newNode entirely
|
||||||
}
|
}
|
||||||
@@ -41,7 +70,7 @@ export function validateIncomingNode(
|
|||||||
// A public key is considered matching if the incoming key equals
|
// A public key is considered matching if the incoming key equals
|
||||||
// the existing key, OR if the existing key is empty.
|
// the existing key, OR if the existing key is empty.
|
||||||
const isKeyMatchingOrExistingEmpty =
|
const isKeyMatchingOrExistingEmpty =
|
||||||
oldNode.user?.publicKey === newNode.user?.publicKey ||
|
equalKey(oldNode.user?.publicKey, newNode.user?.publicKey) ||
|
||||||
oldNode.user?.publicKey === undefined ||
|
oldNode.user?.publicKey === undefined ||
|
||||||
oldNode.user?.publicKey.length === 0;
|
oldNode.user?.publicKey.length === 0;
|
||||||
|
|
||||||
@@ -49,14 +78,34 @@ export function validateIncomingNode(
|
|||||||
// Keys match or existing key was empty: trust the incoming node data completely.
|
// Keys match or existing key was empty: trust the incoming node data completely.
|
||||||
// This allows for legitimate updates to user info and other fields.
|
// This allows for legitimate updates to user info and other fields.
|
||||||
return newNode;
|
return newNode;
|
||||||
} else {
|
} else if (
|
||||||
|
newNode.user?.publicKey !== undefined &&
|
||||||
|
newNode.user?.publicKey.length > 0
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Node ${num} rejected: existing key does not match incoming key. Old key:`,
|
||||||
|
fromByteArray(oldNode.user?.publicKey ?? new Uint8Array()),
|
||||||
|
"New key:",
|
||||||
|
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
|
||||||
|
);
|
||||||
|
|
||||||
// Keys do not match and existing key was not empty: potential impersonation attempt.
|
// Keys do not match and existing key was not empty: potential impersonation attempt.
|
||||||
setNodeError(num, "MISMATCH_PKI");
|
setNodeError(num, "MISMATCH_PKI");
|
||||||
return oldNode; // drop newNode fields and return old
|
return oldNode; // drop newNode fields and return old
|
||||||
|
} else {
|
||||||
|
// Incoming node has no public key: ignore the new node entirely.
|
||||||
|
console.warn(
|
||||||
|
`Node ${num} rejected: incoming node has no public key, but existing does.`,
|
||||||
|
);
|
||||||
|
return oldNode; // drop newNode fields and return old
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Multiple existing nodes with the same node number
|
// Multiple existing nodes with the same node number
|
||||||
// This should never happen, but if it does, we drop the new node entirely.
|
// This should never happen, but if it does, we drop the new node entirely.
|
||||||
|
console.warn(
|
||||||
|
`Node ${num} rejected: Multiple existing nodes with this node number.`,
|
||||||
|
);
|
||||||
|
|
||||||
setNodeError(num, "DUPLICATE_PKI");
|
setNodeError(num, "DUPLICATE_PKI");
|
||||||
return undefined; // drop newNode entirely
|
return undefined; // drop newNode entirely
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
||||||
|
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
|
import type { StoreApi, UseBoundStore } from "zustand";
|
||||||
|
import { shallow } from "zustand/shallow";
|
||||||
|
|
||||||
|
type GenericEqualityFn<T> = (a: T, b: T) => boolean;
|
||||||
|
|
||||||
|
type DebounceOpts<T> = {
|
||||||
|
debounce?: number; // 0/undefined = no debounce
|
||||||
|
equality?: GenericEqualityFn<T>; // default: shallow
|
||||||
|
fireImmediately?: boolean; // default: true
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreWithSelector<S> = UseBoundStore<StoreApi<S>> & {
|
||||||
|
getState(): S;
|
||||||
|
subscribe: <U>(
|
||||||
|
selector: (state: S) => U,
|
||||||
|
listener: (next: U, prev: U) => void,
|
||||||
|
options?: { equalityFn?: GenericEqualityFn<U>; fireImmediately?: boolean },
|
||||||
|
) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bindStoreToDevice<S, DB>(
|
||||||
|
store: StoreWithSelector<S>,
|
||||||
|
resolveDB: (state: S, deviceId: number) => DB,
|
||||||
|
) {
|
||||||
|
// Overloads:
|
||||||
|
function useBound(): DB;
|
||||||
|
function useBound<T>(selector: (db: DB) => T, opts?: DebounceOpts<T>): T;
|
||||||
|
|
||||||
|
// Implementation:
|
||||||
|
function useBound<T>(
|
||||||
|
selector?: (db: DB) => T,
|
||||||
|
opts?: DebounceOpts<T>,
|
||||||
|
): DB | T {
|
||||||
|
const { deviceId } = useDeviceContext();
|
||||||
|
|
||||||
|
// Build the store-level selector
|
||||||
|
const storeSelector = useCallback(
|
||||||
|
(state: S) => {
|
||||||
|
const db = resolveDB(state, deviceId);
|
||||||
|
return selector ? selector(db) : db;
|
||||||
|
},
|
||||||
|
[deviceId, resolveDB, selector],
|
||||||
|
);
|
||||||
|
|
||||||
|
type Selected = ReturnType<typeof storeSelector>;
|
||||||
|
|
||||||
|
const wait = opts?.debounce ?? 0;
|
||||||
|
const fireImmediately = opts?.fireImmediately ?? true;
|
||||||
|
const equality: GenericEqualityFn<Selected> =
|
||||||
|
(opts?.equality as GenericEqualityFn<Selected>) ??
|
||||||
|
(shallow as unknown as GenericEqualityFn<Selected>);
|
||||||
|
|
||||||
|
const snapRef = useRef<Selected>(storeSelector(store.getState()));
|
||||||
|
snapRef.current = storeSelector(store.getState()); // this ensures rerenders with a new selector (new deviceId) see the right initial value
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribe = useMemo(() => {
|
||||||
|
return (onChange: () => void) => {
|
||||||
|
const unsubscribe = store.subscribe(
|
||||||
|
storeSelector,
|
||||||
|
(next: Selected, prev: Selected) => {
|
||||||
|
const emit = () => {
|
||||||
|
snapRef.current = next;
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (equality(next, prev)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wait > 0) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(emit, wait);
|
||||||
|
} else {
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ equalityFn: equality, fireImmediately },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}, [store, storeSelector, equality, wait, fireImmediately]);
|
||||||
|
|
||||||
|
const getSnapshot = () => snapRef.current;
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useBound;
|
||||||
|
}
|
||||||
@@ -11,11 +11,19 @@ import { cn } from "@core/utils/cn.ts";
|
|||||||
import type { Protobuf } from "@meshtastic/core";
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
import { bbox, lineString } from "@turf/turf";
|
import { bbox, lineString } from "@turf/turf";
|
||||||
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
||||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
||||||
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
||||||
import { Avatar } from "../../components/UI/Avatar.tsx";
|
import { Avatar } from "../../components/UI/Avatar.tsx";
|
||||||
|
|
||||||
|
const NODEDB_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
type NodePosition = {
|
type NodePosition = {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -31,7 +39,19 @@ const convertToLatLng = (position?: {
|
|||||||
|
|
||||||
const MapPage = () => {
|
const MapPage = () => {
|
||||||
const { waypoints } = useDevice();
|
const { waypoints } = useDevice();
|
||||||
const { getNodes, hasNodeError } = useNodeDB();
|
const { nodes: validNodes, hasNodeError } = useNodeDB(
|
||||||
|
(db) => ({
|
||||||
|
// only nodes with a position
|
||||||
|
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo =>
|
||||||
|
Boolean(n.position?.latitudeI),
|
||||||
|
),
|
||||||
|
hasNodeError: db.hasNodeError,
|
||||||
|
// include the Map reference so error badges update when nodeErrors changes
|
||||||
|
_errorsRef: db.nodeErrors,
|
||||||
|
}),
|
||||||
|
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||||
|
);
|
||||||
|
|
||||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||||
|
|
||||||
const { default: map } = useMap();
|
const { default: map } = useMap();
|
||||||
@@ -39,14 +59,6 @@ const MapPage = () => {
|
|||||||
const [selectedNode, setSelectedNode] =
|
const [selectedNode, setSelectedNode] =
|
||||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||||
|
|
||||||
const validNodes = useMemo(
|
|
||||||
() =>
|
|
||||||
getNodes((node): node is Protobuf.Mesh.NodeInfo =>
|
|
||||||
Boolean(node.position?.latitudeI),
|
|
||||||
),
|
|
||||||
[getNodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filterState, setFilterState] = useState<FilterState>(
|
const [filterState, setFilterState] = useState<FilterState>(
|
||||||
() => defaultFilterValues,
|
() => defaultFilterValues,
|
||||||
);
|
);
|
||||||
@@ -57,6 +69,8 @@ const MapPage = () => {
|
|||||||
[validNodes, deferredFilterState, nodeFilter],
|
[validNodes, deferredFilterState, nodeFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasFitBoundsOnce = useRef(false);
|
||||||
|
|
||||||
const handleMarkerClick = useCallback(
|
const handleMarkerClick = useCallback(
|
||||||
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
||||||
event?.originalEvent?.stopPropagation();
|
event?.originalEvent?.stopPropagation();
|
||||||
@@ -76,7 +90,7 @@ const MapPage = () => {
|
|||||||
|
|
||||||
// Get the bounds of the map based on the nodes furtherest away from center
|
// Get the bounds of the map based on the nodes furtherest away from center
|
||||||
const getMapBounds = useCallback(() => {
|
const getMapBounds = useCallback(() => {
|
||||||
if (!map || validNodes.length === 0) {
|
if (hasFitBoundsOnce.current || !map || validNodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +122,7 @@ const MapPage = () => {
|
|||||||
if (center) {
|
if (center) {
|
||||||
map.easeTo(center);
|
map.easeTo(center);
|
||||||
}
|
}
|
||||||
|
hasFitBoundsOnce.current = true;
|
||||||
}, [map, validNodes]);
|
}, [map, validNodes]);
|
||||||
|
|
||||||
// Generate all markers
|
// Generate all markers
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { base16 } from "rfc4648";
|
import { base16 } from "rfc4648";
|
||||||
|
|
||||||
|
const NODEDB_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface DeleteNoteDialogProps {
|
export interface DeleteNoteDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -41,7 +42,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
const { t } = useTranslation("nodes");
|
const { t } = useTranslation("nodes");
|
||||||
const { currentLanguage } = useLang();
|
const { currentLanguage } = useLang();
|
||||||
const { hardware, connection, setDialogOpen } = useDevice();
|
const { hardware, connection, setDialogOpen } = useDevice();
|
||||||
const { getNodes, hasNodeError } = useNodeDB();
|
|
||||||
const { setNodeNumDetails } = useAppStore();
|
const { setNodeNumDetails } = useAppStore();
|
||||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||||
|
|
||||||
@@ -57,11 +58,21 @@ const NodesPage = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
const deferredFilterState = useDeferredValue(filterState);
|
const deferredFilterState = useDeferredValue(filterState);
|
||||||
|
|
||||||
const filteredNodes = useMemo(
|
// stable predicate so the selector identity doesn’t thrash
|
||||||
() => getNodes((node) => nodeFilter(node, deferredFilterState)),
|
const predicate = useCallback(
|
||||||
[deferredFilterState, getNodes, nodeFilter],
|
(node: Protobuf.Mesh.NodeInfo) => nodeFilter(node, deferredFilterState),
|
||||||
|
[nodeFilter, deferredFilterState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// subscribe to actual data (nodes array) and to nodeErrors ref for badge updates
|
||||||
|
const { nodes: filteredNodes, hasNodeError } = useNodeDB(
|
||||||
|
(db) => ({
|
||||||
|
nodes: db.getNodes(predicate, false),
|
||||||
|
hasNodeError: db.hasNodeError,
|
||||||
|
_errorsRef: db.nodeErrors, // include the Map ref so UI also re-renders on error changes
|
||||||
|
}),
|
||||||
|
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||||
|
);
|
||||||
const handleTraceroute = useCallback(
|
const handleTraceroute = useCallback(
|
||||||
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
|
||||||
setSelectedTraceroute(traceroute);
|
setSelectedTraceroute(traceroute);
|
||||||
@@ -103,7 +114,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
connection.events.onPositionPacket.subscribe(handleLocation);
|
||||||
return () => {
|
return () => {
|
||||||
connection.events.onPositionPacket.subscribe(handleLocation);
|
connection.events.onPositionPacket.unsubscribe(handleLocation);
|
||||||
};
|
};
|
||||||
}, [connection, handleLocation]);
|
}, [connection, handleLocation]);
|
||||||
|
|
||||||
@@ -125,6 +136,15 @@ const NodesPage = (): JSX.Element => {
|
|||||||
.match(/.{1,2}/g)
|
.match(/.{1,2}/g)
|
||||||
?.join(":") ?? t("unknown.shortName");
|
?.join(":") ?? t("unknown.shortName");
|
||||||
|
|
||||||
|
const shortName =
|
||||||
|
node.user?.shortName ??
|
||||||
|
numberToHexUnpadded(node.num).slice(-4).toUpperCase();
|
||||||
|
const longName =
|
||||||
|
node.user?.longName ??
|
||||||
|
t("fallbackName", {
|
||||||
|
last4: shortName,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.num,
|
id: node.num,
|
||||||
isFavorite: node.isFavorite,
|
isFavorite: node.isFavorite,
|
||||||
@@ -132,12 +152,12 @@ const NodesPage = (): JSX.Element => {
|
|||||||
{
|
{
|
||||||
content: (
|
content: (
|
||||||
<Avatar
|
<Avatar
|
||||||
text={node.user?.shortName ?? t("unknown.shortName")}
|
text={shortName}
|
||||||
showFavorite={node.isFavorite}
|
showFavorite={node.isFavorite}
|
||||||
showError={hasNodeError(node.num)}
|
showError={hasNodeError(node.num)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
sortValue: node.user?.shortName ?? "", // Non-sortable column
|
sortValue: shortName, // Non-sortable column
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: (
|
content: (
|
||||||
@@ -148,10 +168,10 @@ const NodesPage = (): JSX.Element => {
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer underline ml-2 whitespace-break-spaces"
|
className="cursor-pointer underline ml-2 whitespace-break-spaces"
|
||||||
>
|
>
|
||||||
{node.user?.longName ?? numberToHexUnpadded(node.num)}
|
{longName}
|
||||||
</h1>
|
</h1>
|
||||||
),
|
),
|
||||||
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
|
sortValue: longName,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: (
|
content: (
|
||||||
@@ -164,7 +184,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
? t("unit.hop.plural")
|
? t("unit.hop.plural")
|
||||||
: t("unit.hops_one")
|
: t("unit.hops_one")
|
||||||
} ${t("nodesTable.connectionStatus.away")}`
|
} ${t("nodesTable.connectionStatus.away")}`
|
||||||
: t("nodesTable.connectionStatus.unknown")}
|
: t("unknown.longName")}
|
||||||
{node?.viaMqtt === true
|
{node?.viaMqtt === true
|
||||||
? t("nodesTable.connectionStatus.viaMqtt")
|
? t("nodesTable.connectionStatus.viaMqtt")
|
||||||
: ""}
|
: ""}
|
||||||
@@ -176,7 +196,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
content: (
|
content: (
|
||||||
<Mono>
|
<Mono>
|
||||||
{node.lastHeard === 0 ? (
|
{node.lastHeard === 0 ? (
|
||||||
<p>{t("nodesTable.lastHeardStatus.never")}</p>
|
t("unknown.longName")
|
||||||
) : (
|
) : (
|
||||||
<TimeAgo
|
<TimeAgo
|
||||||
timestamp={node.lastHeard * 1000}
|
timestamp={node.lastHeard * 1000}
|
||||||
|
|||||||
Reference in New Issue
Block a user