feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
function createDefaultUser(num: number): Protobuf.Mesh.User {
|
||||
const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
|
||||
const userId = `!${userIdHex}`;
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const longName = `Meshtastic ${last4}`;
|
||||
const shortName = last4;
|
||||
|
||||
return create(Protobuf.Mesh.UserSchema, {
|
||||
id: userId,
|
||||
longName: longName.toString(),
|
||||
shortName: shortName.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
|
||||
if (!node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!node.user) {
|
||||
if (node.num === undefined || node.num === null) {
|
||||
console.error(
|
||||
`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
|
||||
node,
|
||||
);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Assign the created user directly
|
||||
node.user = createDefaultUser(node.num);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MessageState, MessageType } from "@core/stores";
|
||||
import type { Message } from "@core/stores/messageStore/types.ts";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number; // (timestamp ms)
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
type: MessageType;
|
||||
|
||||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||
this.channel = data.channel;
|
||||
this.to = data.to;
|
||||
this.from = data.from;
|
||||
this.messageId = data.id;
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||
this.message = data.data;
|
||||
this.type = data.type === "direct" ? MessageType.Direct : MessageType.Broadcast;
|
||||
|
||||
let dateTimestamp = Date.now();
|
||||
if (data.rxTime instanceof Date) {
|
||||
const timeValue = data.rxTime.getTime();
|
||||
|
||||
if (!Number.isNaN(timeValue)) {
|
||||
dateTimestamp = timeValue;
|
||||
}
|
||||
} else if (data.rxTime != null) {
|
||||
console.warn(
|
||||
`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`,
|
||||
);
|
||||
}
|
||||
this.date = dateTimestamp;
|
||||
}
|
||||
|
||||
toMessage(): Message {
|
||||
return {
|
||||
channel: this.channel,
|
||||
to: this.to,
|
||||
from: this.from,
|
||||
date: this.date,
|
||||
messageId: this.messageId,
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PacketToMessageDTO;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
|
||||
|
||||
interface BrowserSupport {
|
||||
supported: BrowserFeature[];
|
||||
unsupported: BrowserFeature[];
|
||||
}
|
||||
|
||||
export function useBrowserFeatureDetection(): BrowserSupport {
|
||||
const support = useMemo(() => {
|
||||
const features: [BrowserFeature, boolean][] = [
|
||||
["Web Bluetooth", !!navigator.bluetooth],
|
||||
["Web Serial", !!navigator.serial],
|
||||
[
|
||||
"Secure Context",
|
||||
globalThis.location.protocol === "https:" || globalThis.location.hostname === "localhost",
|
||||
],
|
||||
];
|
||||
|
||||
return features.reduce<BrowserSupport>(
|
||||
(acc, [feature, isSupported]) => {
|
||||
const list = isSupported ? acc.supported : acc.unsupported;
|
||||
list.push(feature);
|
||||
return acc;
|
||||
},
|
||||
{ supported: [], unsupported: [] },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return support;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface CookieHookResult<T> {
|
||||
value: T | undefined;
|
||||
setCookie: (value: T, options?: Cookies.CookieAttributes) => void;
|
||||
removeCookie: () => void;
|
||||
}
|
||||
|
||||
function useCookie<T extends object>(cookieName: string, initialValue?: T): CookieHookResult<T> {
|
||||
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
|
||||
try {
|
||||
const cookie = Cookies.get(cookieName);
|
||||
return cookie ? (JSON.parse(cookie) as T) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing cookie ${cookieName}:`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setCookie = useCallback(
|
||||
(value: T, options?: Cookies.CookieAttributes) => {
|
||||
try {
|
||||
Cookies.set(cookieName, JSON.stringify(value), options);
|
||||
setCookieValue(value);
|
||||
} catch (error) {
|
||||
console.error(`Error setting cookie ${cookieName}:`, error);
|
||||
}
|
||||
},
|
||||
[cookieName],
|
||||
);
|
||||
|
||||
const removeCookie = useCallback(() => {
|
||||
try {
|
||||
Cookies.remove(cookieName);
|
||||
setCookieValue(undefined);
|
||||
} catch (error) {
|
||||
console.error(`Error removing cookie ${cookieName}:`, error);
|
||||
}
|
||||
}, [cookieName]);
|
||||
|
||||
return {
|
||||
value: cookieValue,
|
||||
setCookie,
|
||||
removeCookie,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCookie;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseCopyToClipboardProps {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
globalThis.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string) => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn("Clipboard API not available");
|
||||
setIsCopied(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
globalThis.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
|
||||
timeoutRef.current = globalThis.setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, timeout);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy text to clipboard:", error);
|
||||
setIsCopied(false);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[timeout],
|
||||
);
|
||||
|
||||
return { isCopied, copy };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type DeviceContext = {
|
||||
deviceId: number; // Unique identifier for the device, not nodeNum
|
||||
};
|
||||
|
||||
export const CurrentDeviceContext = createContext<DeviceContext | undefined>(undefined);
|
||||
|
||||
export function useDeviceContext(): DeviceContext {
|
||||
const ctx = useContext(CurrentDeviceContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDeviceContext must be used within CurrentDeviceContext provider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useFavoriteNode } from "./useFavoriteNode.ts";
|
||||
|
||||
const mockNode = {
|
||||
num: 1234,
|
||||
user: {
|
||||
longName: "Test Node",
|
||||
},
|
||||
isFavorite: true,
|
||||
} as unknown | Protobuf.Mesh.NodeInfo;
|
||||
|
||||
const mockUpdateFavorite = vi.fn();
|
||||
const mockGetNode = vi.fn(() => mockNode);
|
||||
const mockToast = vi.fn();
|
||||
const mockSendAdminMessage = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useNodeDB: () => ({
|
||||
updateFavorite: mockUpdateFavorite,
|
||||
getNode: mockGetNode,
|
||||
}),
|
||||
useDevice: () => ({
|
||||
sendAdminMessage: mockSendAdminMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||
useToast: () => ({
|
||||
toast: mockToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useFavoriteNode hook", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls updateFavorite and shows correct toast", () => {
|
||||
const { result } = renderHook(() => useFavoriteNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateFavorite({ nodeNum: 1234, isFavorite: true });
|
||||
});
|
||||
|
||||
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
|
||||
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Added Test Node to favorites.",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles removal case", () => {
|
||||
const { result } = renderHook(() => useFavoriteNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateFavorite({ nodeNum: 1234, isFavorite: false });
|
||||
});
|
||||
|
||||
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
|
||||
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Removed Test Node from favorites.",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'node' if longName is missing", () => {
|
||||
mockGetNode.mockReturnValueOnce({
|
||||
num: 5678,
|
||||
user: {},
|
||||
}); // no longName
|
||||
|
||||
const { result } = renderHook(() => useFavoriteNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateFavorite({ nodeNum: 5678, isFavorite: true });
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Added Node to favorites.",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'node' if getNode returns undefined", () => {
|
||||
mockGetNode.mockReturnValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useFavoriteNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
|
||||
});
|
||||
|
||||
expect(mockUpdateFavorite).not.toHaveBeenCalled();
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FavoriteNodeOptions {
|
||||
nodeNum: number;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export function useFavoriteNode() {
|
||||
const { sendAdminMessage } = useDevice();
|
||||
const { getNode, updateFavorite } = useNodeDB();
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const updateFavoriteCB = useCallback(
|
||||
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
|
||||
const node = getNode(nodeNum);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendAdminMessage(
|
||||
create(Protobuf.Admin.AdminMessageSchema, {
|
||||
payloadVariant: {
|
||||
case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
|
||||
value: nodeNum,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// TODO: Wait for response before changing the store
|
||||
updateFavorite(nodeNum, isFavorite);
|
||||
|
||||
toast({
|
||||
title: t("toast.favoriteNode.title", {
|
||||
action: isFavorite
|
||||
? t("toast.favoriteNode.action.added")
|
||||
: t("toast.favoriteNode.action.removed"),
|
||||
nodeName: node?.user?.longName ?? t("node"),
|
||||
direction: isFavorite
|
||||
? t("toast.favoriteNode.action.to")
|
||||
: t("toast.favoriteNode.action.from"),
|
||||
}),
|
||||
});
|
||||
},
|
||||
[updateFavorite, sendAdminMessage, getNode, t, toast],
|
||||
);
|
||||
|
||||
return { updateFavorite: updateFavoriteCB };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type FlagKey, type Flags, featureFlags } from "@core/services/featureFlags.ts";
|
||||
import * as React from "react";
|
||||
|
||||
export function useFeatureFlags(): Flags {
|
||||
return React.useSyncExternalStore(
|
||||
(cb) => featureFlags.subscribe(cb),
|
||||
() => featureFlags.all(),
|
||||
() => featureFlags.all(),
|
||||
);
|
||||
}
|
||||
|
||||
export function useFeatureFlag(key: FlagKey): boolean {
|
||||
return React.useSyncExternalStore(
|
||||
(cb) => featureFlags.subscribe(cb),
|
||||
() => featureFlags.get(key),
|
||||
() => featureFlags.get(key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useIgnoreNode } from "./useIgnoreNode.ts";
|
||||
|
||||
const mockNode = {
|
||||
num: 1234,
|
||||
user: {
|
||||
longName: "Test Node",
|
||||
},
|
||||
isIgnored: true,
|
||||
} as unknown | Protobuf.Mesh.NodeInfo;
|
||||
|
||||
const mockUpdateIgnore = vi.fn();
|
||||
const mockGetNode = vi.fn(() => mockNode);
|
||||
const mockToast = vi.fn();
|
||||
const mockSendAdminMessage = vi.fn();
|
||||
|
||||
vi.mock("@core/stores", () => ({
|
||||
CurrentDeviceContext: {
|
||||
_currentValue: { deviceId: 1234 },
|
||||
},
|
||||
useNodeDB: () => ({
|
||||
updateIgnore: mockUpdateIgnore,
|
||||
getNode: mockGetNode,
|
||||
}),
|
||||
useDevice: () => ({
|
||||
sendAdminMessage: mockSendAdminMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||
useToast: () => ({
|
||||
toast: mockToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useIgnoreNode hook", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls updateIgnored and shows correct toast", () => {
|
||||
const { result } = renderHook(() => useIgnoreNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateIgnored({ nodeNum: 1234, isIgnored: true });
|
||||
});
|
||||
|
||||
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
|
||||
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Added Test Node to ignore list",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles removal case", () => {
|
||||
const { result } = renderHook(() => useIgnoreNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateIgnored({
|
||||
nodeNum: 1234,
|
||||
isIgnored: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
|
||||
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Removed Test Node from ignore list",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'node' if longName is missing", () => {
|
||||
mockGetNode.mockReturnValueOnce({
|
||||
num: 5678,
|
||||
user: {},
|
||||
}); // no longName
|
||||
|
||||
const { result } = renderHook(() => useIgnoreNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateIgnored({ nodeNum: 5678, isIgnored: true });
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Added node to ignore list",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'node' if getNode returns undefined", () => {
|
||||
mockGetNode.mockReturnValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useIgnoreNode());
|
||||
|
||||
act(() => {
|
||||
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
|
||||
});
|
||||
|
||||
expect(mockUpdateIgnore).not.toHaveBeenCalled();
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface IgnoreNodeOptions {
|
||||
nodeNum: number;
|
||||
isIgnored: boolean;
|
||||
}
|
||||
|
||||
export function useIgnoreNode() {
|
||||
const { sendAdminMessage } = useDevice();
|
||||
const { getNode, updateIgnore } = useNodeDB();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const updateIgnoredCB = useCallback(
|
||||
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
|
||||
const node = getNode(nodeNum);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendAdminMessage(
|
||||
create(Protobuf.Admin.AdminMessageSchema, {
|
||||
payloadVariant: {
|
||||
case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
|
||||
value: nodeNum,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// TODO: Wait for response before changing the store
|
||||
updateIgnore(nodeNum, isIgnored);
|
||||
|
||||
toast({
|
||||
title: t("toast.ignoreNode.title", {
|
||||
nodeName: node?.user?.longName ?? "node",
|
||||
action: isIgnored
|
||||
? t("toast.ignoreNode.action.added")
|
||||
: t("toast.ignoreNode.action.removed"),
|
||||
direction: isIgnored
|
||||
? t("toast.ignoreNode.action.to")
|
||||
: t("toast.ignoreNode.action.from"),
|
||||
}),
|
||||
});
|
||||
},
|
||||
[sendAdminMessage, updateIgnore, getNode, t, toast],
|
||||
);
|
||||
|
||||
return { updateIgnored: updateIgnoredCB };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface UseBackupReminderOptions {
|
||||
reminderInDays?: number;
|
||||
message: string;
|
||||
onAccept?: () => void | Promise<void>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ReminderState {
|
||||
expires: string;
|
||||
}
|
||||
|
||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
|
||||
const TOAST_DURATION = 30_000; // 30 seconds
|
||||
const REMINDER_DAYS_ONE_WEEK = 7;
|
||||
const REMINDER_DAYS_ONE_YEAR = 365;
|
||||
const REMINDER_DAYS_FOREVER = 3650;
|
||||
const STORAGE_KEY = "key_backup_reminder";
|
||||
|
||||
function isReminderExpired(expires?: string): boolean {
|
||||
if (!expires) {
|
||||
return true;
|
||||
}
|
||||
const expiryDate = new Date(expires);
|
||||
if (Number.isNaN(expiryDate.getTime())) {
|
||||
return true; // Invalid date passed
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return now.getTime() >= expiryDate.getTime();
|
||||
}
|
||||
|
||||
export function useBackupReminder({
|
||||
enabled,
|
||||
message,
|
||||
onAccept = () => {},
|
||||
reminderInDays = REMINDER_DAYS_ONE_WEEK,
|
||||
}: UseBackupReminderOptions) {
|
||||
const { t } = useTranslation("dialog");
|
||||
|
||||
const { toast } = useToast();
|
||||
const toastShownRef = useRef(false);
|
||||
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
|
||||
STORAGE_KEY,
|
||||
null,
|
||||
);
|
||||
|
||||
const setReminderExpiry = useCallback(
|
||||
(days: number) => {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + days);
|
||||
setReminderState({ expires: expiryDate.toISOString() });
|
||||
},
|
||||
[setReminderState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || toastShownRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isReminderExpired(reminderState?.expires)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastShownRef.current = true;
|
||||
|
||||
const { dismiss } = toast({
|
||||
title: t("pkiBackupReminder.title"),
|
||||
duration: TOAST_DURATION,
|
||||
delay: TOAST_APPEAR_DELAY,
|
||||
description: message,
|
||||
action: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
setReminderExpiry(reminderInDays);
|
||||
}}
|
||||
>
|
||||
{t("pkiBackupReminder.remindLaterPrefix")} {reminderInDays}{" "}
|
||||
{reminderInDays > 1
|
||||
? t("common:unit.day.plural").toLowerCase()
|
||||
: t("common:unit.day.one").toLowerCase()}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
setReminderExpiry(REMINDER_DAYS_FOREVER);
|
||||
}}
|
||||
>
|
||||
{t("pkiBackupReminder.remindNever")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onAccept();
|
||||
dismiss();
|
||||
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
|
||||
}}
|
||||
>
|
||||
{t("pkiBackupReminder.backupNow")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return () => dismiss();
|
||||
}, [
|
||||
enabled,
|
||||
message,
|
||||
onAccept,
|
||||
reminderInDays,
|
||||
reminderState?.expires,
|
||||
setReminderExpiry,
|
||||
t,
|
||||
toast,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* A minimal serializable wrapper so we can store metadata alongside items.
|
||||
*/
|
||||
type PersistedPayload<J = unknown> = {
|
||||
v: 1; // schema version
|
||||
capacity: number; // last used capacity (for info/migrations)
|
||||
items: J[]; // serialized items (MRU -> LRU)
|
||||
};
|
||||
|
||||
export type UseLRUListOptions<T, J = unknown> = {
|
||||
/** localStorage key */
|
||||
key: string;
|
||||
/** max number of items to keep (>=1) */
|
||||
capacity: number;
|
||||
/** optional initial items used when storage is empty/invalid */
|
||||
initial?: T[];
|
||||
/** equality to de-duplicate items; default: Object.is */
|
||||
eq?: (a: T, b: T) => boolean;
|
||||
/** convert T -> JSON-safe type; default: (x) => x as unknown as J */
|
||||
toJSON?: (t: T) => J;
|
||||
/** convert JSON-safe type -> T; default: (x) => x as unknown as T */
|
||||
fromJSON?: (j: J) => T;
|
||||
/** storage impl (for tests); default: window.localStorage */
|
||||
storage?: Storage;
|
||||
/** listen to storage events and live-sync across tabs/windows; default: true */
|
||||
syncTabs?: boolean;
|
||||
};
|
||||
|
||||
export type UseLRUListReturn<T> = {
|
||||
/** Items ordered MRU -> LRU */
|
||||
items: T[];
|
||||
/** Add or "touch" an item (move to MRU); inserts if missing */
|
||||
add: (item: T) => void;
|
||||
/** Remove a matching item (no-op if missing) */
|
||||
remove: (item: T) => void;
|
||||
/** Clear all items */
|
||||
clear: () => void;
|
||||
/** Whether a matching item exists */
|
||||
has: (item: T) => boolean;
|
||||
/** Replace the entire list (applies LRU trimming) */
|
||||
replaceAll: (next: T[]) => void;
|
||||
/** Current capacity (for information) */
|
||||
capacity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* useLRUList<T> – maintains a most-recently-used list and persists it to localStorage.
|
||||
*
|
||||
* MRU is index 0. Adding an existing item "touches" it (moves to front).
|
||||
*/
|
||||
export function useLRUList<T, J = unknown>(opts: UseLRUListOptions<T, J>): UseLRUListReturn<T> {
|
||||
const {
|
||||
key,
|
||||
capacity,
|
||||
initial = [],
|
||||
eq = Object.is,
|
||||
toJSON = (x: T) => x as unknown as J,
|
||||
fromJSON = (x: J) => x as unknown as T,
|
||||
storage = typeof window !== "undefined" ? window.localStorage : undefined,
|
||||
syncTabs = true,
|
||||
} = opts;
|
||||
|
||||
if (capacity < 1) {
|
||||
// Fail fast in dev; silently coerce in prod
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
throw new Error("useLRUList: capacity must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveCapacity = Math.max(1, capacity);
|
||||
|
||||
// Guard against SSR or no-storage environments
|
||||
const canPersist = !!storage && typeof storage.getItem === "function";
|
||||
|
||||
const readPersisted = useCallback((): T[] | null => {
|
||||
if (!canPersist) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedPayload<J>;
|
||||
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
|
||||
return null;
|
||||
}
|
||||
const deserialized = parsed.items.map(fromJSON);
|
||||
return trimToCapacity(deserialized, effectiveCapacity);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [canPersist, storage, key, fromJSON, effectiveCapacity]);
|
||||
|
||||
const writePersisted = useCallback(
|
||||
(items: T[]) => {
|
||||
if (!canPersist) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: PersistedPayload<J> = {
|
||||
v: 1,
|
||||
capacity: effectiveCapacity,
|
||||
items: items.map(toJSON),
|
||||
};
|
||||
storage.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Swallow quota/serialization errors; keep in-memory state working
|
||||
}
|
||||
},
|
||||
[canPersist, storage, key, toJSON, effectiveCapacity],
|
||||
);
|
||||
|
||||
// Initialize from storage (or fallback to `initial`)
|
||||
const [items, setItems] = useState<T[]>(
|
||||
() => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
|
||||
);
|
||||
|
||||
// Keep a ref to avoid feedback loops when applying remote (storage event) updates
|
||||
const applyingExternal = useRef(false);
|
||||
|
||||
// Persist on changes
|
||||
useEffect(() => {
|
||||
if (applyingExternal.current) {
|
||||
applyingExternal.current = false;
|
||||
return;
|
||||
}
|
||||
writePersisted(items);
|
||||
}, [items, writePersisted]);
|
||||
|
||||
// Cross-tab synchronization via storage events
|
||||
useEffect(() => {
|
||||
if (!syncTabs || !canPersist || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.storageArea !== storage || e.key !== key) {
|
||||
return;
|
||||
}
|
||||
// Another tab changed it; re-read safely
|
||||
const next = readPersisted();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
applyingExternal.current = true;
|
||||
setItems(next);
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [syncTabs, canPersist, storage, key, readPersisted]);
|
||||
|
||||
// Helpers
|
||||
const indexOf = useCallback((arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)), [eq]);
|
||||
|
||||
const add = useCallback(
|
||||
(item: T) => {
|
||||
setItems((prev) => {
|
||||
const idx = indexOf(prev, item);
|
||||
if (idx === 0) {
|
||||
return prev; // already MRU
|
||||
}
|
||||
if (idx > 0) {
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
next.unshift(item);
|
||||
return next;
|
||||
}
|
||||
// Not present: insert at MRU and trim
|
||||
const next = [item, ...prev];
|
||||
return trimToCapacity(next, effectiveCapacity);
|
||||
});
|
||||
},
|
||||
[indexOf, effectiveCapacity],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(item: T) => {
|
||||
setItems((prev) => {
|
||||
const idx = indexOf(prev, item);
|
||||
if (idx === -1) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[indexOf],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => setItems([]), []);
|
||||
|
||||
const has = useCallback((item: T) => indexOf(items, item) !== -1, [items, indexOf]);
|
||||
|
||||
const replaceAll = useCallback(
|
||||
(next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
|
||||
[effectiveCapacity],
|
||||
);
|
||||
|
||||
// Stable API shape
|
||||
return useMemo(
|
||||
() => ({
|
||||
items,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
has,
|
||||
replaceAll,
|
||||
capacity: effectiveCapacity,
|
||||
}),
|
||||
[items, add, remove, clear, has, replaceAll, effectiveCapacity],
|
||||
);
|
||||
}
|
||||
|
||||
// --- utils ---
|
||||
|
||||
function trimToCapacity<T>(arr: T[], capacity: number): T[] {
|
||||
if (arr.length <= capacity) {
|
||||
return arr;
|
||||
}
|
||||
return arr.slice(0, capacity);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
type Lang,
|
||||
type LangCode,
|
||||
supportedLanguages,
|
||||
} from "@app/i18n-config.ts";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "./useLocalStorage.ts";
|
||||
|
||||
const STORAGE_KEY = "language";
|
||||
|
||||
type LanguageState = {
|
||||
language: LangCode;
|
||||
};
|
||||
|
||||
function useLang() {
|
||||
const { i18n } = useTranslation();
|
||||
const [_, setLanguageInStorage] = useLocalStorage<LanguageState | null>(STORAGE_KEY, null);
|
||||
|
||||
const currentLanguage = useMemo((): Lang | undefined => {
|
||||
const lang = supportedLanguages.find((l) => l.code === i18n.language);
|
||||
if (lang) {
|
||||
return lang;
|
||||
}
|
||||
return supportedLanguages.find((l) => l.code === FALLBACK_LANGUAGE_CODE);
|
||||
}, [i18n.language]);
|
||||
|
||||
const collator = useMemo(() => {
|
||||
return new Intl.Collator(i18n.language, { sensitivity: "base" });
|
||||
}, [i18n.language]);
|
||||
|
||||
const set = useCallback(
|
||||
async (lng: LangCode, persist = true) => {
|
||||
if (i18n.language === lng) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (persist) {
|
||||
setLanguageInStorage({ language: lng });
|
||||
}
|
||||
await i18n.changeLanguage(lng);
|
||||
} catch (e) {
|
||||
console.warn("Failed to change language:", e);
|
||||
}
|
||||
},
|
||||
[i18n, setLanguageInStorage],
|
||||
);
|
||||
|
||||
const getSupportedLangs = useMemo(
|
||||
() => supportedLanguages.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[],
|
||||
);
|
||||
|
||||
const compare = useCallback(
|
||||
(a: string, b: string) => {
|
||||
return collator.compare(a, b);
|
||||
},
|
||||
[collator],
|
||||
);
|
||||
|
||||
return { compare, set, current: currentLanguage, getSupportedLangs };
|
||||
}
|
||||
|
||||
export default useLang;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import useLocalStorage from "./useLocalStorage.ts";
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
const key = "test-key";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should initialize with initial value if localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("initial");
|
||||
});
|
||||
|
||||
it("should read existing value from localStorage", () => {
|
||||
localStorage.setItem(key, JSON.stringify("stored"));
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("stored");
|
||||
});
|
||||
|
||||
it("should update localStorage when setValue is called", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [, setValue] = result.current;
|
||||
|
||||
act(() => {
|
||||
setValue("updated");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
|
||||
expect(result.current[0]).toBe("updated");
|
||||
});
|
||||
|
||||
it("should remove value from localStorage when removeValue is called", () => {
|
||||
const { result } = renderHook(() => useLocalStorage(key, "initial"));
|
||||
const [, setValue, removeValue] = result.current;
|
||||
|
||||
act(() => {
|
||||
setValue("to-be-removed");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
removeValue();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(result.current[0]).toBe("initial");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
"local-storage": CustomEvent<{ key: string }>;
|
||||
}
|
||||
}
|
||||
|
||||
type UseLocalStorageOptions<T> = {
|
||||
serializer?: (value: T) => string;
|
||||
deserializer?: (value: string) => T;
|
||||
initializeWithValue?: boolean;
|
||||
};
|
||||
|
||||
const IS_SERVER = typeof window === "undefined";
|
||||
|
||||
export default function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | (() => T),
|
||||
options: UseLocalStorageOptions<T> = {},
|
||||
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
||||
const { initializeWithValue = true } = options;
|
||||
|
||||
const serializerRef = useRef(options.serializer ?? JSON.stringify);
|
||||
const deserializerRef = useRef(options.deserializer ?? JSON.parse);
|
||||
|
||||
const initialValueRef = useRef<T>(
|
||||
typeof initialValue === "function" ? (initialValue as () => T)() : initialValue,
|
||||
);
|
||||
|
||||
const getInitialValue = useCallback(() => initialValueRef.current, []);
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
if (!initializeWithValue || IS_SERVER) {
|
||||
return getInitialValue();
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? deserializerRef.current(item) : getInitialValue();
|
||||
} catch (err) {
|
||||
console.warn(`Error reading localStorage key “${key}”:`, err);
|
||||
return getInitialValue();
|
||||
}
|
||||
});
|
||||
|
||||
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||
(value) => {
|
||||
if (IS_SERVER) {
|
||||
console.warn(`Tried setting localStorage key “${key}” in a non-client environment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStoredValue((prev) => {
|
||||
const newValue = typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
|
||||
window.localStorage.setItem(key, serializerRef.current(newValue));
|
||||
window.dispatchEvent(new CustomEvent("local-storage", { detail: { key } }));
|
||||
return newValue;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Error setting localStorage key “${key}”:`, err);
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const removeValue = useCallback(() => {
|
||||
if (IS_SERVER) {
|
||||
console.warn(`Tried removing localStorage key “${key}” in a non-client environment.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(getInitialValue());
|
||||
window.dispatchEvent(new CustomEvent("local-storage", { detail: { key } }));
|
||||
} catch (err) {
|
||||
console.warn(`Error removing localStorage key “${key}”:`, err);
|
||||
}
|
||||
}, [key, getInitialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_SERVER) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorageChange = (event: StorageEvent | CustomEvent<{ key: string }>) => {
|
||||
const eventKey = "key" in event ? event.key : event.detail?.key;
|
||||
|
||||
if (eventKey === key) {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
setStoredValue(item ? deserializerRef.current(item) : getInitialValue());
|
||||
} catch (err) {
|
||||
console.warn(`Error syncing localStorage key “${key}”:`, err);
|
||||
setStoredValue(getInitialValue());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
window.addEventListener("local-storage", handleStorageChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
window.removeEventListener("local-storage", handleStorageChange as EventListener);
|
||||
};
|
||||
}, [key, getInitialValue]);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { boundsFromLngLat, type LngLat, toLngLat } from "@core/utils/geo";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
export function useMapFitting(map: MapRef | undefined) {
|
||||
const focusLngLat = useCallback(
|
||||
(position: LngLat) => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const [lng, lat] = position;
|
||||
map.easeTo({
|
||||
center: [lng, lat],
|
||||
zoom: map.getZoom(),
|
||||
});
|
||||
},
|
||||
[map],
|
||||
);
|
||||
|
||||
const fitToNodes = useCallback(
|
||||
(nodes: Protobuf.Mesh.NodeInfo[]) => {
|
||||
if (!map || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.length === 1 && nodes[0]) {
|
||||
return focusLngLat(toLngLat(nodes[0].position));
|
||||
}
|
||||
|
||||
// Build [lng, lat] coords, then let boundsFromLngLat do the turf dance
|
||||
const coords = nodes.map((n) => toLngLat(n.position));
|
||||
const bounds = boundsFromLngLat(coords);
|
||||
if (!bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const center = map.cameraForBounds(bounds, {
|
||||
padding: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
});
|
||||
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
},
|
||||
[map, focusLngLat],
|
||||
);
|
||||
|
||||
return { focusLngLat, fitToNodes };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
export function useNewNodeNum(id: number, nodeInfo: Protobuf.Mesh.MyNodeInfo): void {
|
||||
useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo);
|
||||
useNodeDBStore.getState().getNodeDB(id)?.setNodeNum(nodeInfo.myNodeNum);
|
||||
useMessageStore.getState().getMessageStore(id)?.setNodeNum(nodeInfo.myNodeNum);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
|
||||
|
||||
describe("usePasswordVisibilityToggle Hook", () => {
|
||||
it("should initialize with visibility set to false by default", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(typeof result.current.toggleVisibility).toBe("function");
|
||||
});
|
||||
|
||||
it("should initialize with visibility set to true if initialVisible is true", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle({ initialVisible: true }));
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("should toggle visibility from false to true when toggleVisibility is called", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("should toggle visibility from true to false when toggleVisibility is called", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle({ initialVisible: true }));
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("should toggle visibility correctly multiple times", () => {
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
|
||||
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle());
|
||||
const initialToggleFunc = result.current.toggleVisibility;
|
||||
rerender();
|
||||
expect(result.current.toggleVisibility).toBe(initialToggleFunc);
|
||||
act(() => {
|
||||
result.current.toggleVisibility();
|
||||
});
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface UsePasswordVisibilityToggleProps {
|
||||
initialVisible?: boolean;
|
||||
}
|
||||
/**
|
||||
* Manages the state for toggling password visibility.
|
||||
*
|
||||
* @param {boolean} [options.initialVisible=false]
|
||||
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
|
||||
*/
|
||||
export function usePasswordVisibilityToggle({
|
||||
initialVisible = false,
|
||||
}: UsePasswordVisibilityToggleProps = {}) {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
|
||||
|
||||
const toggleVisibility = useCallback(() => {
|
||||
setIsVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return { isVisible, toggleVisibility };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function usePinnedItems({ storageName }: { storageName: string }) {
|
||||
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
|
||||
|
||||
const togglePinnedItem = useCallback(
|
||||
(label: string) => {
|
||||
setPinnedItems((prev) =>
|
||||
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label],
|
||||
);
|
||||
},
|
||||
[setPinnedItems],
|
||||
);
|
||||
|
||||
return {
|
||||
pinnedItems,
|
||||
togglePinnedItem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const FLAGS_CONFIG = {
|
||||
UNSET: { value: 0, i18nKey: "position.flags.unset" },
|
||||
ALTITUDE: { value: 1, i18nKey: "position.flags.altitude" },
|
||||
ALTITUDE_MSL: { value: 2, i18nKey: "position.flags.altitudeMsl" },
|
||||
ALTITUDE_GEOIDAL_SEPARATION: {
|
||||
value: 4,
|
||||
i18nKey: "position.flags.altitudeGeoidalSeparation",
|
||||
},
|
||||
DOP: {
|
||||
value: 8,
|
||||
i18nKey: "position.flags.dop",
|
||||
},
|
||||
HDOP_VDOP: {
|
||||
value: 16,
|
||||
i18nKey: "position.flags.hdopVdop",
|
||||
},
|
||||
NUM_SATELLITES: {
|
||||
value: 32,
|
||||
i18nKey: "position.flags.numSatellites",
|
||||
},
|
||||
SEQUENCE_NUMBER: {
|
||||
value: 64,
|
||||
i18nKey: "position.flags.sequenceNumber",
|
||||
},
|
||||
TIMESTAMP: { value: 128, i18nKey: "position.flags.timestamp" },
|
||||
VEHICLE_HEADING: {
|
||||
value: 256,
|
||||
i18nKey: "position.flags.vehicleHeading",
|
||||
},
|
||||
VEHICLE_SPEED: { value: 512, i18nKey: "position.flags.vehicleSpeed" },
|
||||
} as const;
|
||||
|
||||
export type FlagName = keyof typeof FLAGS_CONFIG;
|
||||
|
||||
type UsePositionFlagsProps = {
|
||||
decode: (value: number) => FlagName[];
|
||||
encode: (flagNames: FlagName[]) => number;
|
||||
hasFlag: (value: number, flagName: FlagName) => boolean;
|
||||
getAllFlags: () => typeof FLAGS_CONFIG;
|
||||
isValidValue: (value: number) => boolean;
|
||||
flagsValue: number;
|
||||
activeFlags: FlagName[];
|
||||
toggleFlag: (flagName: FlagName) => void;
|
||||
setFlag: (flagName: FlagName, enabled: boolean) => void;
|
||||
setFlags: (value: number) => void;
|
||||
clearFlags: () => void;
|
||||
};
|
||||
|
||||
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
|
||||
const [flagsValue, setFlagsValue] = useState<number>(initialValue);
|
||||
|
||||
const FLAGS_BITMASKS = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
|
||||
) as { [K in FlagName]: (typeof FLAGS_CONFIG)[K]["value"] };
|
||||
}, []);
|
||||
|
||||
const utils = useMemo(() => {
|
||||
const decode = (value: number): FlagName[] => {
|
||||
if (value === FLAGS_CONFIG.UNSET.value) {
|
||||
return ["UNSET"];
|
||||
}
|
||||
|
||||
const activeFlags: FlagName[] = [];
|
||||
for (const key in FLAGS_CONFIG) {
|
||||
const flagName = key as FlagName;
|
||||
const flagConfig = FLAGS_CONFIG[flagName];
|
||||
if (flagConfig.value !== 0 && (value & flagConfig.value) === flagConfig.value) {
|
||||
activeFlags.push(flagName);
|
||||
}
|
||||
}
|
||||
return activeFlags;
|
||||
};
|
||||
|
||||
const encode = (flagNames: FlagName[]): number => {
|
||||
if (flagNames.includes("UNSET") && flagNames.length === 1) {
|
||||
return FLAGS_CONFIG.UNSET.value;
|
||||
}
|
||||
return flagNames.reduce((acc, name) => {
|
||||
if (name === "UNSET") {
|
||||
return acc;
|
||||
}
|
||||
return acc | FLAGS_CONFIG[name].value;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const hasFlag = (value: number, flagName: FlagName): boolean => {
|
||||
return (value & FLAGS_CONFIG[flagName].value) === FLAGS_CONFIG[flagName].value;
|
||||
};
|
||||
|
||||
const getAllFlags = (): typeof FLAGS_CONFIG => {
|
||||
return FLAGS_CONFIG;
|
||||
};
|
||||
|
||||
const isValidValue = (value: number): boolean => {
|
||||
const maxValue = Object.values(FLAGS_BITMASKS)
|
||||
.filter((val) => val !== 0)
|
||||
.reduce((acc, val) => acc | val, 0);
|
||||
return Number.isInteger(value) && value >= 0 && value <= maxValue;
|
||||
};
|
||||
|
||||
return {
|
||||
decode,
|
||||
encode,
|
||||
hasFlag,
|
||||
getAllFlags,
|
||||
isValidValue,
|
||||
};
|
||||
}, [FLAGS_BITMASKS]);
|
||||
|
||||
const toggleFlag = useCallback((flagName: FlagName) => {
|
||||
setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value);
|
||||
}, []);
|
||||
|
||||
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
|
||||
const currentFlagValue = FLAGS_CONFIG[flagName].value;
|
||||
setFlagsValue((prev) => (enabled ? prev | currentFlagValue : prev & ~currentFlagValue));
|
||||
}, []);
|
||||
|
||||
const setFlags = useCallback(
|
||||
(value: number) => {
|
||||
if (!utils.isValidValue(value)) {
|
||||
throw new Error(`Invalid flags value: ${value}`);
|
||||
}
|
||||
setFlagsValue(value);
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
const clearFlags = useCallback(() => {
|
||||
setFlagsValue(FLAGS_CONFIG.UNSET.value);
|
||||
}, []);
|
||||
|
||||
const activeFlags = utils.decode(flagsValue);
|
||||
|
||||
return {
|
||||
...utils,
|
||||
flagsValue,
|
||||
activeFlags,
|
||||
toggleFlag,
|
||||
setFlag,
|
||||
setFlags,
|
||||
clearFlags,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
export function useTheme() {
|
||||
const getSystemTheme = () =>
|
||||
globalThis.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
const getStoredPreference = useCallback(
|
||||
(): Theme => (localStorage.getItem("theme") as Theme) || "system",
|
||||
[],
|
||||
);
|
||||
|
||||
const [preference, setPreference] = useState<Theme>(() =>
|
||||
typeof window !== "undefined" ? getStoredPreference() : "light",
|
||||
);
|
||||
|
||||
const theme = preference === "system" ? getSystemTheme() : preference;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preference !== "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = globalThis.matchMedia("(prefers-color-scheme: dark)");
|
||||
const updateTheme = () => setPreference(getStoredPreference());
|
||||
|
||||
media.addEventListener("change", updateTheme);
|
||||
return () => media.removeEventListener("change", updateTheme);
|
||||
}, [preference, getStoredPreference]);
|
||||
|
||||
const setPreferenceValue = (newPreference: Theme) => {
|
||||
localStorage.setItem("theme", newPreference);
|
||||
setPreference(newPreference);
|
||||
};
|
||||
|
||||
return { theme, preference, setPreference: setPreferenceValue };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("useToast", () => {
|
||||
beforeEach(() => {
|
||||
// Reset toast memory state before each test
|
||||
// our hook uses global memory to store toasts
|
||||
// @ts-expect-error - internal test reset
|
||||
globalThis.memoryState = { toasts: [] };
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should create a toast with title, description, and action", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
act(() => {
|
||||
result.current.toast({
|
||||
title: "Backup Reminder",
|
||||
description: "Don't forget to backup!",
|
||||
action: <Button>Backup Now</Button>,
|
||||
});
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
const toast = result.current.toasts[0]!;
|
||||
expect(result.current.toasts.length).toBe(1);
|
||||
expect(toast.title).toBe("Backup Reminder");
|
||||
expect(toast.description).toBe("Don't forget to backup!");
|
||||
expect(toast.action).toBeTruthy();
|
||||
expect(toast.open).toBe(true);
|
||||
});
|
||||
it("should dismiss a toast using returned dismiss function", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
vi.useFakeTimers();
|
||||
|
||||
let toastRef: { id: string; dismiss: () => void };
|
||||
|
||||
act(() => {
|
||||
toastRef = result.current.toast({ title: "Dismiss Me" });
|
||||
vi.runAllTimers(); // Flush ADD_TOAST
|
||||
});
|
||||
|
||||
act(() => {
|
||||
toastRef.dismiss();
|
||||
});
|
||||
|
||||
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
|
||||
expect(toast?.open).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should allow dismiss via hook dismiss function", () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
vi.useFakeTimers();
|
||||
|
||||
let toastRef: { id: string };
|
||||
|
||||
act(() => {
|
||||
toastRef = result.current.toast({ title: "Manual Dismiss" });
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismiss(toastRef.id);
|
||||
});
|
||||
|
||||
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
|
||||
expect(toast?.open).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
|
||||
import { type ReactNode, useSyncExternalStore } from "react";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ToastActionElement;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
for (const toast of state.toasts) {
|
||||
addToRemoveQueue(toast.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST": {
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
for (const listener of listeners) {
|
||||
listener(memoryState);
|
||||
}
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ delay = 0, ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) {
|
||||
dismiss();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}, delay);
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return function unsubscribe() {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getState = () => {
|
||||
return memoryState;
|
||||
};
|
||||
|
||||
function useToast() {
|
||||
const state = useSyncExternalStore(subscribe, getState);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useToggleVisibility({ timeout }: { timeout?: number } = {}) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
setIsVisible(true);
|
||||
|
||||
if (timeout) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
timeoutRef.current = null;
|
||||
}, timeout);
|
||||
}
|
||||
}, [timeout]);
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [isVisible, show] as const;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useDevice, type ValidConfigType, type ValidModuleConfigType } from "@core/stores";
|
||||
|
||||
type UseWaitForConfigProps =
|
||||
| { configCase: ValidConfigType; moduleConfigCase?: never }
|
||||
| { configCase?: never; moduleConfigCase: ValidModuleConfigType };
|
||||
|
||||
export function useWaitForConfig({ configCase, moduleConfigCase }: UseWaitForConfigProps): void {
|
||||
const { config, moduleConfig } = useDevice();
|
||||
|
||||
const isDataDefined = configCase
|
||||
? config[configCase] !== undefined
|
||||
: moduleConfig[moduleConfigCase as ValidModuleConfigType] !== undefined;
|
||||
|
||||
if (!isDataDefined) {
|
||||
throw new Promise<void>(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { featureFlags } from "@core/services/featureFlags";
|
||||
|
||||
const isDev = typeof import.meta !== "undefined" && import.meta.env?.DEV;
|
||||
console.log(`Dev mode: ${isDev}`);
|
||||
|
||||
if (isDev) {
|
||||
featureFlags.setOverrides({
|
||||
persistNodeDB: true,
|
||||
persistMessages: true,
|
||||
persistApp: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Map feature keys -> env var names (Vite exposes only VITE_*). */
|
||||
export const FLAG_ENV = {
|
||||
persistNodeDB: "VITE_PERSIST_NODE_DB",
|
||||
persistMessages: "VITE_PERSIST_MESSAGES",
|
||||
persistDevices: "VITE_PERSIST_DEVICES",
|
||||
persistApp: "VITE_PERSIST_APP",
|
||||
} as const;
|
||||
|
||||
export type FlagKey = keyof typeof FLAG_ENV;
|
||||
export type Flags = Record<FlagKey, boolean>;
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const present = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v !== undefined);
|
||||
|
||||
const mutableShape: Record<string, z.ZodTypeAny> = {};
|
||||
for (const envName of Object.values(FLAG_ENV)) {
|
||||
mutableShape[envName] = present;
|
||||
}
|
||||
const EnvSchema = z.object(mutableShape);
|
||||
|
||||
class FeatureFlags {
|
||||
private base: Flags;
|
||||
private overrides: Partial<Flags> = {};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
constructor(base: Flags) {
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
get(key: FlagKey): boolean {
|
||||
return this.overrides[key] ?? this.base[key];
|
||||
}
|
||||
|
||||
/** Get all flags */
|
||||
all(): Flags {
|
||||
return { ...this.base, ...this.overrides };
|
||||
}
|
||||
|
||||
/** Optional dev/test override. Pass null to clear. */
|
||||
setOverride(key: FlagKey, val: boolean | null) {
|
||||
if (val === null) {
|
||||
delete this.overrides[key];
|
||||
} else {
|
||||
this.overrides[key] = val;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Set many at once */
|
||||
setOverrides(partial: Partial<Flags>) {
|
||||
for (const [k, v] of Object.entries(partial)) {
|
||||
this.setOverride(k as FlagKey, v as boolean);
|
||||
if (v === null) {
|
||||
delete this.overrides[k as FlagKey];
|
||||
} else {
|
||||
this.overrides[k as FlagKey] = v as boolean;
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
subscribe(fn: Listener) {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private emit() {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFeatureFlags(env: Record<string, unknown>): FeatureFlags {
|
||||
const parsed = EnvSchema.parse(env);
|
||||
const base = Object.fromEntries(
|
||||
(Object.keys(FLAG_ENV) as FlagKey[]).map((k) => [k, parsed[FLAG_ENV[k]] as boolean]),
|
||||
) as Flags;
|
||||
return new FeatureFlags(base);
|
||||
}
|
||||
|
||||
export const featureFlags = createFeatureFlags(import.meta.env);
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { RasterSource } from "@core/stores/appStore/types.ts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||
set: vi.fn((key: string, val: string) => {
|
||||
idbMem.set(key, val);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((key: string) => {
|
||||
idbMem.delete(key);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
async function freshStore(persistApp = false) {
|
||||
vi.resetModules();
|
||||
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
vi.doMock("@core/services/featureFlags.ts", () => ({
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => (key === "persistApp" ? persistApp : false)),
|
||||
},
|
||||
}));
|
||||
|
||||
const storeMod = await import("./index.ts");
|
||||
return storeMod as typeof import("./index.ts");
|
||||
}
|
||||
|
||||
function makeRaster(fields: Record<string, any>): RasterSource {
|
||||
return {
|
||||
enabled: true,
|
||||
title: "default",
|
||||
tiles: `https://default.com/default.json`,
|
||||
tileSize: 256,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppStore – basic state & actions", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("setters flip UI flags and numeric fields", async () => {
|
||||
const { useAppStore } = await freshStore(false);
|
||||
const state = useAppStore.getState();
|
||||
|
||||
state.setSelectedDevice(42);
|
||||
expect(useAppStore.getState().selectedDeviceId).toBe(42);
|
||||
|
||||
state.setCommandPaletteOpen(true);
|
||||
expect(useAppStore.getState().commandPaletteOpen).toBe(true);
|
||||
|
||||
state.setConnectDialogOpen(true);
|
||||
expect(useAppStore.getState().connectDialogOpen).toBe(true);
|
||||
|
||||
state.setNodeNumToBeRemoved(123);
|
||||
expect(useAppStore.getState().nodeNumToBeRemoved).toBe(123);
|
||||
|
||||
state.setNodeNumDetails(777);
|
||||
expect(useAppStore.getState().nodeNumDetails).toBe(777);
|
||||
});
|
||||
|
||||
it("setRasterSources replaces; addRasterSource appends; removeRasterSource splices by index", async () => {
|
||||
const { useAppStore } = await freshStore(false);
|
||||
const state = useAppStore.getState();
|
||||
|
||||
const a = makeRaster({ title: "a" });
|
||||
const b = makeRaster({ title: "b" });
|
||||
const c = makeRaster({ title: "c" });
|
||||
|
||||
state.setRasterSources([a, b]);
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["a", "b"]);
|
||||
|
||||
state.addRasterSource(c);
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
|
||||
// "b"
|
||||
state.removeRasterSource(1);
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppStore – persistence: partialize + rehydrate", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("persists only rasterSources; methods still work after rehydrate", async () => {
|
||||
// Write data
|
||||
{
|
||||
const { useAppStore } = await freshStore(true);
|
||||
const state = useAppStore.getState();
|
||||
|
||||
state.setRasterSources([makeRaster({ title: "x" }), makeRaster({ title: "y" })]);
|
||||
state.setSelectedDevice(99);
|
||||
state.setCommandPaletteOpen(true);
|
||||
// Only rasterSources should persist by partialize
|
||||
expect(useAppStore.getState().rasterSources.length).toBe(2);
|
||||
}
|
||||
|
||||
// Rehydrate from idbMem
|
||||
{
|
||||
const { useAppStore } = await freshStore(true);
|
||||
const state = useAppStore.getState();
|
||||
|
||||
// persisted slice:
|
||||
expect(state.rasterSources.map((raster) => raster.title)).toEqual(["x", "y"]);
|
||||
|
||||
// ephemeral fields reset to defaults:
|
||||
expect(state.selectedDeviceId).toBe(0);
|
||||
expect(state.commandPaletteOpen).toBe(false);
|
||||
expect(state.connectDialogOpen).toBe(false);
|
||||
expect(state.nodeNumToBeRemoved).toBe(0);
|
||||
expect(state.nodeNumDetails).toBe(0);
|
||||
|
||||
// methods still work post-rehydrate:
|
||||
state.addRasterSource(makeRaster({ title: "z" }));
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
]);
|
||||
state.removeRasterSource(0);
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual([
|
||||
"y",
|
||||
"z",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("removing and resetting sources persists across reload", async () => {
|
||||
{
|
||||
const { useAppStore } = await freshStore(true);
|
||||
const state = useAppStore.getState();
|
||||
state.setRasterSources([makeRaster({ title: "keep" }), makeRaster({ title: "drop" })]);
|
||||
state.removeRasterSource(1); // drop "drop"
|
||||
expect(useAppStore.getState().rasterSources.map((raster) => raster.title)).toEqual(["keep"]);
|
||||
}
|
||||
{
|
||||
const { useAppStore } = await freshStore(true);
|
||||
const state = useAppStore.getState();
|
||||
expect(state.rasterSources.map((raster) => raster.title)).toEqual(["keep"]);
|
||||
|
||||
// Now replace entirely
|
||||
state.setRasterSources([]);
|
||||
}
|
||||
{
|
||||
const { useAppStore } = await freshStore(true);
|
||||
const state = useAppStore.getState();
|
||||
expect(state.rasterSources).toEqual([]); // stayed cleared
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { featureFlags } from "@core/services/featureFlags.ts";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
import { produce } from "immer";
|
||||
import { create as createStore, type StateCreator } from "zustand";
|
||||
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
|
||||
import type { RasterSource } from "./types.ts";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-app-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
type AppData = {
|
||||
// Persisted data
|
||||
rasterSources: RasterSource[];
|
||||
};
|
||||
|
||||
export interface AppState extends AppData {
|
||||
// Ephemeral state (not persisted)
|
||||
selectedDeviceId: number;
|
||||
nodeNumToBeRemoved: number;
|
||||
connectDialogOpen: boolean;
|
||||
nodeNumDetails: number;
|
||||
commandPaletteOpen: boolean;
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
removeRasterSource: (index: number) => void;
|
||||
setSelectedDevice: (deviceId: number) => void;
|
||||
setCommandPaletteOpen: (open: boolean) => void;
|
||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||
setConnectDialogOpen: (open: boolean) => void;
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
}
|
||||
|
||||
export const deviceStoreInitializer: StateCreator<AppState> = (set, _get) => ({
|
||||
selectedDeviceId: 0,
|
||||
rasterSources: [],
|
||||
commandPaletteOpen: false,
|
||||
connectDialogOpen: false,
|
||||
nodeNumToBeRemoved: 0,
|
||||
nodeNumDetails: 0,
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.rasterSources = sources;
|
||||
}),
|
||||
);
|
||||
},
|
||||
addRasterSource: (source: RasterSource) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.rasterSources.push(source);
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeRasterSource: (index: number) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.rasterSources.splice(index, 1);
|
||||
}),
|
||||
);
|
||||
},
|
||||
setSelectedDevice: (deviceId) =>
|
||||
set(() => ({
|
||||
selectedDeviceId: deviceId,
|
||||
})),
|
||||
setCommandPaletteOpen: (open: boolean) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.commandPaletteOpen = open;
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeNumToBeRemoved: (nodeNum) =>
|
||||
set(() => ({
|
||||
nodeNumToBeRemoved: nodeNum,
|
||||
})),
|
||||
setConnectDialogOpen: (open: boolean) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.connectDialogOpen = open;
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeNumDetails: (nodeNum) =>
|
||||
set(() => ({
|
||||
nodeNumDetails: nodeNum,
|
||||
})),
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<AppState, AppData> = {
|
||||
name: IDB_KEY_NAME,
|
||||
storage: createStorage<AppData>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): AppData => ({
|
||||
rasterSources: s.rasterSources,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
console.debug("AppStore: Rehydrating state", state);
|
||||
},
|
||||
};
|
||||
|
||||
// Add persist middleware on the store if the feature flag is enabled
|
||||
const persistApps = featureFlags.get("persistApp");
|
||||
console.debug(`AppStore: Persisting app is ${persistApps ? "enabled" : "disabled"}`);
|
||||
|
||||
export const useAppStore = persistApps
|
||||
? createStore(subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)))
|
||||
: createStore(subscribeWithSelector(deviceStoreInitializer));
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface RasterSource {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
tiles: string;
|
||||
tileSize: number;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Types } from "@meshtastic/core";
|
||||
|
||||
// Config type discriminators
|
||||
export type ValidConfigType =
|
||||
| "device"
|
||||
| "position"
|
||||
| "power"
|
||||
| "network"
|
||||
| "display"
|
||||
| "lora"
|
||||
| "bluetooth"
|
||||
| "security";
|
||||
|
||||
export type ValidModuleConfigType =
|
||||
| "mqtt"
|
||||
| "serial"
|
||||
| "externalNotification"
|
||||
| "storeForward"
|
||||
| "rangeTest"
|
||||
| "telemetry"
|
||||
| "cannedMessage"
|
||||
| "audio"
|
||||
| "neighborInfo"
|
||||
| "ambientLighting"
|
||||
| "detectionSensor"
|
||||
| "paxcounter"
|
||||
| "remoteHardware"
|
||||
| "statusmessage"
|
||||
| "trafficManagement"
|
||||
| "tak";
|
||||
|
||||
// Admin message types that can be queued
|
||||
export type ValidAdminMessageType = "setFixedPosition" | "other";
|
||||
|
||||
// Unified config change key type
|
||||
export type ConfigChangeKey =
|
||||
| { type: "config"; variant: ValidConfigType }
|
||||
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
||||
| { type: "channel"; index: Types.ChannelNumber }
|
||||
| { type: "user" }
|
||||
| { type: "adminMessage"; variant: ValidAdminMessageType; id: string };
|
||||
|
||||
// Serialized key for Map storage
|
||||
export type ConfigChangeKeyString = string;
|
||||
|
||||
// Registry entry
|
||||
export interface ChangeEntry {
|
||||
key: ConfigChangeKey;
|
||||
value: unknown;
|
||||
timestamp: number;
|
||||
originalValue?: unknown;
|
||||
}
|
||||
|
||||
// The unified registry
|
||||
export interface ChangeRegistry {
|
||||
changes: Map<ConfigChangeKeyString, ChangeEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert structured key to string for Map lookup
|
||||
*/
|
||||
export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
|
||||
switch (key.type) {
|
||||
case "config":
|
||||
return `config:${key.variant}`;
|
||||
case "moduleConfig":
|
||||
return `moduleConfig:${key.variant}`;
|
||||
case "channel":
|
||||
return `channel:${key.index}`;
|
||||
case "user":
|
||||
return "user";
|
||||
case "adminMessage":
|
||||
return `adminMessage:${key.variant}:${key.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse operation for type-safe retrieval
|
||||
*/
|
||||
export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
|
||||
const parts = keyStr.split(":");
|
||||
const type = parts[0];
|
||||
|
||||
switch (type) {
|
||||
case "config":
|
||||
return { type: "config", variant: parts[1] as ValidConfigType };
|
||||
case "moduleConfig":
|
||||
return {
|
||||
type: "moduleConfig",
|
||||
variant: parts[1] as ValidModuleConfigType,
|
||||
};
|
||||
case "channel":
|
||||
return {
|
||||
type: "channel",
|
||||
index: Number(parts[1]) as Types.ChannelNumber,
|
||||
};
|
||||
case "user":
|
||||
return { type: "user" };
|
||||
case "adminMessage":
|
||||
return {
|
||||
type: "adminMessage",
|
||||
variant: parts[1] as ValidAdminMessageType,
|
||||
id: parts[2] ?? "",
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown key type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty change registry
|
||||
*/
|
||||
export function createChangeRegistry(): ChangeRegistry {
|
||||
return {
|
||||
changes: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a config variant has changes
|
||||
*/
|
||||
export function hasConfigChange(registry: ChangeRegistry, variant: ValidConfigType): boolean {
|
||||
return registry.changes.has(serializeKey({ type: "config", variant }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module config variant has changes
|
||||
*/
|
||||
export function hasModuleConfigChange(
|
||||
registry: ChangeRegistry,
|
||||
variant: ValidModuleConfigType,
|
||||
): boolean {
|
||||
return registry.changes.has(serializeKey({ type: "moduleConfig", variant }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel has changes
|
||||
*/
|
||||
export function hasChannelChange(registry: ChangeRegistry, index: Types.ChannelNumber): boolean {
|
||||
return registry.changes.has(serializeKey({ type: "channel", index }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user config has changes
|
||||
*/
|
||||
export function hasUserChange(registry: ChangeRegistry): boolean {
|
||||
return registry.changes.has(serializeKey({ type: "user" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of config changes
|
||||
*/
|
||||
export function getConfigChangeCount(registry: ChangeRegistry): number {
|
||||
let count = 0;
|
||||
for (const keyStr of registry.changes.keys()) {
|
||||
const key = deserializeKey(keyStr);
|
||||
if (key.type === "config") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of module config changes
|
||||
*/
|
||||
export function getModuleConfigChangeCount(registry: ChangeRegistry): number {
|
||||
let count = 0;
|
||||
for (const keyStr of registry.changes.keys()) {
|
||||
const key = deserializeKey(keyStr);
|
||||
if (key.type === "moduleConfig") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of channel changes
|
||||
*/
|
||||
export function getChannelChangeCount(registry: ChangeRegistry): number {
|
||||
let count = 0;
|
||||
for (const keyStr of registry.changes.keys()) {
|
||||
const key = deserializeKey(keyStr);
|
||||
if (key.type === "channel") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all config changes as an array
|
||||
*/
|
||||
export function getAllConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
|
||||
const changes: ChangeEntry[] = [];
|
||||
for (const entry of registry.changes.values()) {
|
||||
if (entry.key.type === "config") {
|
||||
changes.push(entry);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all module config changes as an array
|
||||
*/
|
||||
export function getAllModuleConfigChanges(registry: ChangeRegistry): ChangeEntry[] {
|
||||
const changes: ChangeEntry[] = [];
|
||||
for (const entry of registry.changes.values()) {
|
||||
if (entry.key.type === "moduleConfig") {
|
||||
changes.push(entry);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channel changes as an array
|
||||
*/
|
||||
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
|
||||
const changes: ChangeEntry[] = [];
|
||||
for (const entry of registry.changes.values()) {
|
||||
if (entry.key.type === "channel") {
|
||||
changes.push(entry);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin message changes as an array
|
||||
*/
|
||||
export function getAllAdminMessages(registry: ChangeRegistry): ChangeEntry[] {
|
||||
const changes: ChangeEntry[] = [];
|
||||
for (const entry of registry.changes.values()) {
|
||||
if (entry.key.type === "adminMessage") {
|
||||
changes.push(entry);
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of admin message changes
|
||||
*/
|
||||
export function getAdminMessageChangeCount(registry: ChangeRegistry): number {
|
||||
let count = 0;
|
||||
for (const keyStr of registry.changes.keys()) {
|
||||
const key = deserializeKey(keyStr);
|
||||
if (key.type === "adminMessage") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { vi } from "vitest";
|
||||
import type { Device } from "./index.ts";
|
||||
|
||||
/**
|
||||
* You can spread this base mock in your tests and override only the
|
||||
* properties relevant to a specific test case.
|
||||
*
|
||||
* @example
|
||||
* vi.mocked(useDevice).mockReturnValue({
|
||||
* ...mockDeviceStore,
|
||||
* getNode: (nodeNum) => mockNodes.get(nodeNum),
|
||||
* });
|
||||
*/
|
||||
export const mockDeviceStore: Device = {
|
||||
id: 0,
|
||||
myNodeNum: 123456,
|
||||
status: 5 as const,
|
||||
channels: new Map(),
|
||||
config: {} as Protobuf.LocalOnly.LocalConfig,
|
||||
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
|
||||
changeRegistry: { changes: new Map() },
|
||||
hardware: {} as Protobuf.Mesh.MyNodeInfo,
|
||||
metadata: new Map(),
|
||||
traceroutes: new Map(),
|
||||
connection: undefined,
|
||||
activeNode: 0,
|
||||
waypoints: [],
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
unreadCounts: new Map(),
|
||||
dialog: {
|
||||
import: false,
|
||||
QR: false,
|
||||
shutdown: false,
|
||||
reboot: false,
|
||||
deviceName: false,
|
||||
nodeRemoval: false,
|
||||
pkiBackup: false,
|
||||
nodeDetails: false,
|
||||
unsafeRoles: false,
|
||||
refreshKeys: false,
|
||||
deleteMessages: false,
|
||||
managedMode: false,
|
||||
clientNotification: false,
|
||||
resetNodeDb: false,
|
||||
clearAllStores: false,
|
||||
factoryResetConfig: false,
|
||||
factoryResetDevice: false,
|
||||
},
|
||||
clientNotifications: [],
|
||||
neighborInfo: new Map(),
|
||||
|
||||
setStatus: vi.fn(),
|
||||
setConfig: vi.fn(),
|
||||
setModuleConfig: vi.fn(),
|
||||
getEffectiveConfig: vi.fn(),
|
||||
getEffectiveModuleConfig: vi.fn(),
|
||||
setHardware: vi.fn(),
|
||||
setActiveNode: vi.fn(),
|
||||
setPendingSettingsChanges: vi.fn(),
|
||||
addChannel: vi.fn(),
|
||||
addWaypoint: vi.fn(),
|
||||
removeWaypoint: vi.fn(),
|
||||
getWaypoint: vi.fn(),
|
||||
addConnection: vi.fn(),
|
||||
addTraceRoute: vi.fn(),
|
||||
addMetadata: vi.fn(),
|
||||
setDialogOpen: vi.fn(),
|
||||
getDialogOpen: vi.fn().mockReturnValue(false),
|
||||
setMessageDraft: vi.fn(),
|
||||
incrementUnread: vi.fn(),
|
||||
resetUnread: vi.fn(),
|
||||
sendAdminMessage: vi.fn(),
|
||||
addClientNotification: vi.fn(),
|
||||
removeClientNotification: vi.fn(),
|
||||
getClientNotification: vi.fn(),
|
||||
getAllUnreadCount: vi.fn().mockReturnValue(0),
|
||||
getUnreadCount: vi.fn().mockReturnValue(0),
|
||||
getNeighborInfo: vi.fn(),
|
||||
addNeighborInfo: vi.fn(),
|
||||
|
||||
// New unified change tracking methods
|
||||
setChange: vi.fn(),
|
||||
removeChange: vi.fn(),
|
||||
hasChange: vi.fn().mockReturnValue(false),
|
||||
getChange: vi.fn(),
|
||||
clearAllChanges: vi.fn(),
|
||||
hasConfigChange: vi.fn().mockReturnValue(false),
|
||||
hasModuleConfigChange: vi.fn().mockReturnValue(false),
|
||||
hasChannelChange: vi.fn().mockReturnValue(false),
|
||||
hasUserChange: vi.fn().mockReturnValue(false),
|
||||
getConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||
getChannelChangeCount: vi.fn().mockReturnValue(0),
|
||||
getAllConfigChanges: vi.fn().mockReturnValue([]),
|
||||
getAllModuleConfigChanges: vi.fn().mockReturnValue([]),
|
||||
getAllChannelChanges: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
@@ -0,0 +1,531 @@
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||
set: vi.fn((key: string, val: string) => {
|
||||
idbMem.set(key, val);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
idbMem.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper to load a fresh copy of the store with persist flag on/off
|
||||
async function freshStore(persist = false) {
|
||||
vi.resetModules();
|
||||
|
||||
// suppress console output from the store during tests (for github actions)
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
vi.doMock("@core/services/featureFlags", () => ({
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => (key === "persistDevices" ? persist : false)),
|
||||
},
|
||||
}));
|
||||
|
||||
const storeMod = await import("./index.ts");
|
||||
const { useNodeDB } = await import("../index.ts");
|
||||
return { ...storeMod, useNodeDB };
|
||||
}
|
||||
|
||||
function makeHardware(myNodeNum: number) {
|
||||
return create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum });
|
||||
}
|
||||
function makeRoute(from: number, time = Date.now() / 1000) {
|
||||
return {
|
||||
from,
|
||||
rxTime: time,
|
||||
portnum: Protobuf.Portnums.PortNum.ROUTING_APP,
|
||||
data: create(Protobuf.Mesh.RouteDiscoverySchema, {}),
|
||||
} as unknown as Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>;
|
||||
}
|
||||
function makeChannel(index: number) {
|
||||
return create(Protobuf.Channel.ChannelSchema, { index });
|
||||
}
|
||||
function makeWaypoint(id: number, expire?: number) {
|
||||
return create(Protobuf.Mesh.WaypointSchema, { id, expire });
|
||||
}
|
||||
|
||||
function makeAdminMessage(fields: Record<string, any>) {
|
||||
return create(Protobuf.Admin.AdminMessageSchema, fields);
|
||||
}
|
||||
|
||||
describe("DeviceStore – basic map ops & retention", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("addDevice returns same instance on repeated calls; getDevice(s) works; retention evicts oldest after cap", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
|
||||
const a = state.addDevice(1);
|
||||
const b = state.addDevice(1);
|
||||
expect(a).toBe(b);
|
||||
expect(state.getDevice(1)).toBe(a);
|
||||
expect(state.getDevices().length).toBe(1);
|
||||
|
||||
// DEVICESTORE_RETENTION_NUM = 10; create 11 to evict #1
|
||||
for (let i = 2; i <= 11; i++) {
|
||||
state.addDevice(i);
|
||||
}
|
||||
expect(state.getDevice(1)).toBeUndefined();
|
||||
expect(state.getDevice(11)).toBeDefined();
|
||||
expect(state.getDevices().length).toBe(10);
|
||||
});
|
||||
|
||||
it("removeDevice deletes only that entry", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
state.addDevice(10);
|
||||
state.addDevice(11);
|
||||
expect(state.getDevices().length).toBe(2);
|
||||
|
||||
state.removeDevice(10);
|
||||
expect(state.getDevice(10)).toBeUndefined();
|
||||
expect(state.getDevice(11)).toBeDefined();
|
||||
expect(state.getDevices().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceStore – change registry API", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("setChange/hasChange/getChange for config and getEffectiveConfig merges base + working", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(42);
|
||||
|
||||
// config deviceConfig.role = CLIENT
|
||||
device.setConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "device",
|
||||
value: create(Protobuf.Config.Config_DeviceConfigSchema, {
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// working deviceConfig.role = ROUTER
|
||||
const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, {
|
||||
role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
|
||||
});
|
||||
device.setChange({ type: "config", variant: "device" }, routerConfig);
|
||||
|
||||
// expect change is tracked
|
||||
expect(device.hasConfigChange("device")).toBe(true);
|
||||
const working = device.getChange({
|
||||
type: "config",
|
||||
variant: "device",
|
||||
}) as Protobuf.Config.Config_DeviceConfig;
|
||||
expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
|
||||
|
||||
// expect effective deviceConfig.role = ROUTER
|
||||
const effective = device.getEffectiveConfig("device");
|
||||
expect(effective?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER);
|
||||
|
||||
// remove change, effective should equal base
|
||||
device.removeChange({ type: "config", variant: "device" });
|
||||
expect(device.hasConfigChange("device")).toBe(false);
|
||||
expect(device.getEffectiveConfig("device")?.role).toBe(
|
||||
Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
|
||||
);
|
||||
|
||||
// add multiple, then clear all
|
||||
device.setChange({ type: "config", variant: "device" }, routerConfig);
|
||||
device.setChange({ type: "config", variant: "position" }, {});
|
||||
device.clearAllChanges();
|
||||
expect(device.hasConfigChange("device")).toBe(false);
|
||||
expect(device.hasConfigChange("position")).toBe(false);
|
||||
});
|
||||
|
||||
it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(7);
|
||||
|
||||
// base moduleConfig.mqtt with base address
|
||||
device.setModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "mqtt",
|
||||
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
|
||||
address: "mqtt://base",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// working mqtt config
|
||||
const workingMqtt = create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
|
||||
address: "mqtt://working",
|
||||
});
|
||||
device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
|
||||
|
||||
expect(device.hasModuleConfigChange("mqtt")).toBe(true);
|
||||
const mqtt = device.getChange({
|
||||
type: "moduleConfig",
|
||||
variant: "mqtt",
|
||||
}) as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig;
|
||||
expect(mqtt?.address).toBe("mqtt://working");
|
||||
|
||||
// effective should return working value
|
||||
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe("mqtt://working");
|
||||
|
||||
// remove change
|
||||
device.removeChange({ type: "moduleConfig", variant: "mqtt" });
|
||||
expect(device.hasModuleConfigChange("mqtt")).toBe(false);
|
||||
expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe("mqtt://base");
|
||||
|
||||
// Clear all
|
||||
device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt);
|
||||
device.clearAllChanges();
|
||||
expect(device.hasModuleConfigChange("mqtt")).toBe(false);
|
||||
});
|
||||
|
||||
it("setModuleConfig stores the firmware-current module variants", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const device = useDeviceStore.getState().addDevice(11);
|
||||
|
||||
device.setModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "trafficManagement",
|
||||
value: create(Protobuf.ModuleConfig.ModuleConfig_TrafficManagementConfigSchema, {
|
||||
enabled: true,
|
||||
rateLimitMaxPackets: 5,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
device.setModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "statusmessage",
|
||||
value: create(Protobuf.ModuleConfig.ModuleConfig_StatusMessageConfigSchema, {
|
||||
nodeStatus: "online",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
device.setModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "remoteHardware",
|
||||
value: create(Protobuf.ModuleConfig.ModuleConfig_RemoteHardwareConfigSchema, {
|
||||
enabled: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(device.getEffectiveModuleConfig("trafficManagement")?.enabled).toBe(true);
|
||||
expect(device.getEffectiveModuleConfig("trafficManagement")?.rateLimitMaxPackets).toBe(5);
|
||||
expect(device.getEffectiveModuleConfig("statusmessage")?.nodeStatus).toBe("online");
|
||||
expect(device.getEffectiveModuleConfig("remoteHardware")?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("channel change tracking add/update/remove/get", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(9);
|
||||
|
||||
const channel0 = makeChannel(0);
|
||||
const channel1 = create(Protobuf.Channel.ChannelSchema, {
|
||||
index: 1,
|
||||
settings: { name: "one" },
|
||||
});
|
||||
|
||||
device.setChange({ type: "channel", index: 0 }, channel0);
|
||||
device.setChange({ type: "channel", index: 1 }, channel1);
|
||||
|
||||
expect(device.hasChannelChange(0)).toBe(true);
|
||||
expect(device.hasChannelChange(1)).toBe(true);
|
||||
const ch0 = device.getChange({ type: "channel", index: 0 }) as
|
||||
| Protobuf.Channel.Channel
|
||||
| undefined;
|
||||
expect(ch0?.index).toBe(0);
|
||||
const ch1 = device.getChange({ type: "channel", index: 1 }) as
|
||||
| Protobuf.Channel.Channel
|
||||
| undefined;
|
||||
expect(ch1?.settings?.name).toBe("one");
|
||||
|
||||
// update channel 1
|
||||
const channel1Updated = create(Protobuf.Channel.ChannelSchema, {
|
||||
index: 1,
|
||||
settings: { name: "uno" },
|
||||
});
|
||||
device.setChange({ type: "channel", index: 1 }, channel1Updated);
|
||||
const ch1Updated = device.getChange({ type: "channel", index: 1 }) as
|
||||
| Protobuf.Channel.Channel
|
||||
| undefined;
|
||||
expect(ch1Updated?.settings?.name).toBe("uno");
|
||||
|
||||
// remove specific
|
||||
device.removeChange({ type: "channel", index: 1 });
|
||||
expect(device.hasChannelChange(1)).toBe(false);
|
||||
|
||||
// remove all
|
||||
device.clearAllChanges();
|
||||
expect(device.hasChannelChange(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceStore – metadata, dialogs, unread counts, message draft", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("addMetadata stores by node id", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(1);
|
||||
|
||||
const metadata = create(Protobuf.Mesh.DeviceMetadataSchema, {
|
||||
firmwareVersion: "1.2.3",
|
||||
});
|
||||
device.addMetadata(123, metadata);
|
||||
|
||||
expect(useDeviceStore.getState().devices.get(1)?.metadata.get(123)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("dialogs set/get work and throw if device missing", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(5);
|
||||
|
||||
device.setDialogOpen("reboot", true);
|
||||
expect(device.getDialogOpen("reboot")).toBe(true);
|
||||
device.setDialogOpen("reboot", false);
|
||||
expect(device.getDialogOpen("reboot")).toBe(false);
|
||||
|
||||
// getDialogOpen uses getDevice or throws if device missing
|
||||
state.removeDevice(5);
|
||||
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/);
|
||||
});
|
||||
|
||||
it("unread counts: increment/get/getAll/reset", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(2);
|
||||
|
||||
expect(device.getUnreadCount(10)).toBe(0);
|
||||
device.incrementUnread(10);
|
||||
device.incrementUnread(10);
|
||||
device.incrementUnread(11);
|
||||
expect(device.getUnreadCount(10)).toBe(2);
|
||||
expect(device.getUnreadCount(11)).toBe(1);
|
||||
expect(device.getAllUnreadCount()).toBe(3);
|
||||
|
||||
device.resetUnread(10);
|
||||
expect(device.getUnreadCount(10)).toBe(0);
|
||||
expect(device.getAllUnreadCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("setMessageDraft stores the text", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const device = useDeviceStore.getState().addDevice(3);
|
||||
device.setMessageDraft("hello");
|
||||
|
||||
expect(useDeviceStore.getState().devices.get(3)?.messageDraft).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceStore – traceroutes & waypoints retention + merge on setHardware", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("addTraceRoute appends and enforces per-target and target caps", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(100);
|
||||
|
||||
// Per target: cap = 100; push 101 for from=7
|
||||
for (let i = 0; i < 101; i++) {
|
||||
device.addTraceRoute(makeRoute(7, i));
|
||||
}
|
||||
|
||||
const routesFor7 = useDeviceStore.getState().devices.get(100)?.traceroutes.get(7)!;
|
||||
expect(routesFor7.length).toBe(100);
|
||||
expect(routesFor7[0]?.rxTime).toBe(1); // first (0) evicted
|
||||
|
||||
// Target map cap: 100 keys, add 101 unique "from"
|
||||
for (let from = 0; from <= 100; from++) {
|
||||
device.addTraceRoute(makeRoute(1000 + from));
|
||||
}
|
||||
|
||||
const keys = Array.from(useDeviceStore.getState().devices.get(100)!.traceroutes.keys());
|
||||
expect(keys.length).toBe(100);
|
||||
});
|
||||
|
||||
it("addWaypoint upserts by id and enforces retention; setHardware moves traceroutes + prunes expired waypoints", async () => {
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
|
||||
// Old device with myNodeNum=777 and some waypoints (one expired)
|
||||
const oldDevice = state.addDevice(1);
|
||||
const mockSendWaypoint = vi.fn();
|
||||
oldDevice.addConnection({ sendWaypoint: mockSendWaypoint } as any);
|
||||
|
||||
oldDevice.setHardware(makeHardware(777));
|
||||
oldDevice.addWaypoint(
|
||||
makeWaypoint(1, Date.parse("2024-12-31T23:59:59Z")), // This is expired, will not be added
|
||||
0,
|
||||
0,
|
||||
new Date(),
|
||||
); // expired
|
||||
oldDevice.addWaypoint(makeWaypoint(2, 0), 0, 0, new Date()); // no expire
|
||||
oldDevice.addWaypoint(makeWaypoint(3, Date.parse("2026-01-01T00:00:00Z")), 0, 0, new Date()); // ok
|
||||
oldDevice.addTraceRoute(makeRoute(55));
|
||||
oldDevice.addTraceRoute(makeRoute(56));
|
||||
|
||||
// Upsert waypoint by id
|
||||
oldDevice.addWaypoint(makeWaypoint(2, Date.parse("2027-01-01T00:00:00Z")), 0, 0, new Date());
|
||||
|
||||
const wps = useDeviceStore.getState().devices.get(1)!.waypoints;
|
||||
expect(wps.length).toBe(2);
|
||||
expect(wps.find((w) => w.id === 2)?.expire).toBe(Date.parse("2027-01-01T00:00:00Z"));
|
||||
|
||||
// Retention: push 102 total waypoints -> capped at 100. Oldest evicted
|
||||
for (let i = 3; i <= 102; i++) {
|
||||
oldDevice.addWaypoint(makeWaypoint(i), 0, 0, new Date());
|
||||
}
|
||||
|
||||
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(100);
|
||||
|
||||
// Remove waypoint
|
||||
oldDevice.removeWaypoint(102, false);
|
||||
expect(mockSendWaypoint).not.toHaveBeenCalled();
|
||||
|
||||
await oldDevice.removeWaypoint(101, true); // toMesh=true
|
||||
expect(mockSendWaypoint).toHaveBeenCalled();
|
||||
|
||||
expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98);
|
||||
|
||||
// New device shares myNodeNum; setHardware should:
|
||||
// - move traceroutes from old device
|
||||
// - copy waypoints minus expired
|
||||
// - delete old device entry
|
||||
const newDevice = state.addDevice(2);
|
||||
newDevice.setHardware(makeHardware(777));
|
||||
|
||||
expect(state.getDevice(1)).toBeUndefined();
|
||||
expect(state.getDevice(2)).toBeDefined();
|
||||
|
||||
// traceroutes moved:
|
||||
expect(state.getDevice(2)!.traceroutes.size).toBe(2);
|
||||
|
||||
// Getter for waypoint by id works
|
||||
expect(newDevice.getWaypoint(1)).toBeUndefined();
|
||||
expect(newDevice.getWaypoint(2)).toBeUndefined();
|
||||
expect(newDevice.getWaypoint(3)).toBeTruthy();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceStore – persistence partialize & rehydrate", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("partialize stores only DeviceData; onRehydrateStorage rebuilds only devices with myNodeNum set (orphans dropped)", async () => {
|
||||
// First run: persist=true
|
||||
{
|
||||
const { useDeviceStore } = await freshStore(true);
|
||||
const state = useDeviceStore.getState();
|
||||
|
||||
const orphan = state.addDevice(500); // no myNodeNum -> should be dropped
|
||||
orphan.addWaypoint(makeWaypoint(123), 0, 0, new Date());
|
||||
|
||||
const good = state.addDevice(501);
|
||||
good.setHardware(makeHardware(42)); // sets myNodeNum
|
||||
good.addTraceRoute(makeRoute(77));
|
||||
good.addWaypoint(makeWaypoint(1), 0, 0, new Date());
|
||||
// ensure some ephemeral fields differ so we can verify methods work after rehydrate
|
||||
good.setMessageDraft("draft");
|
||||
}
|
||||
|
||||
// Reload: persist=true -> rehydrate from idbMem
|
||||
{
|
||||
const { useDeviceStore } = await freshStore(true);
|
||||
const state = useDeviceStore.getState();
|
||||
|
||||
expect(state.getDevice(500)).toBeUndefined(); // orphan dropped
|
||||
const device = state.getDevice(501)!;
|
||||
expect(device).toBeDefined();
|
||||
|
||||
// methods should work
|
||||
device.addWaypoint(makeWaypoint(2), 0, 0, new Date());
|
||||
expect(useDeviceStore.getState().devices.get(501)!.waypoints.length).toBeGreaterThan(0);
|
||||
|
||||
// traceroutes survived
|
||||
expect(useDeviceStore.getState().devices.get(501)!.traceroutes.size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("removing a device persists across reload", async () => {
|
||||
{
|
||||
const { useDeviceStore } = await freshStore(true);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(900);
|
||||
device.setHardware(makeHardware(9)); // ensure it will be rehydrated
|
||||
expect(state.getDevice(900)).toBeDefined();
|
||||
state.removeDevice(900);
|
||||
expect(state.getDevice(900)).toBeUndefined();
|
||||
}
|
||||
{
|
||||
const { useDeviceStore } = await freshStore(true);
|
||||
expect(useDeviceStore.getState().getDevice(900)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceStore – connection & sendAdminMessage", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sendAdminMessage calls through to connection.sendPacket with correct args", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(77);
|
||||
|
||||
const sendPacket = vi.fn();
|
||||
device.addConnection({ sendPacket } as any);
|
||||
|
||||
const message = makeAdminMessage({ logVerbosity: 1 });
|
||||
device.sendAdminMessage(message);
|
||||
|
||||
expect(sendPacket).toHaveBeenCalledTimes(1);
|
||||
const [bytes, port, dest] = sendPacket.mock.calls[0]!;
|
||||
expect(port).toBe(Protobuf.Portnums.PortNum.ADMIN_APP);
|
||||
expect(dest).toBe("self");
|
||||
|
||||
// sanity: encoded bytes match toBinary on the same schema
|
||||
const expected = toBinary(Protobuf.Admin.AdminMessageSchema, message);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
|
||||
// compare content length as minimal assertion (exact byte-for-byte is fine too)
|
||||
expect((bytes as Uint8Array).length).toBe(expected.length);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import type { Device } from "./index.ts";
|
||||
import { useDeviceStore } from "./index.ts";
|
||||
import type { Connection, ConnectionId } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Hook to get the currently active connection
|
||||
*/
|
||||
export function useActiveConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.getActiveConnection());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the HTTP connection marked as default
|
||||
*/
|
||||
export function useDefaultConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.savedConnections.find((c) => c.isDefault));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the first saved connection
|
||||
*/
|
||||
export function useFirstSavedConnection(): Connection | undefined {
|
||||
return useDeviceStore((s) => s.savedConnections.at(0));
|
||||
}
|
||||
|
||||
export function useAddSavedConnection() {
|
||||
return useDeviceStore((s) => s.addSavedConnection);
|
||||
}
|
||||
|
||||
export function useUpdateSavedConnection() {
|
||||
return useDeviceStore((s) => s.updateSavedConnection);
|
||||
}
|
||||
|
||||
export function useRemoveSavedConnection() {
|
||||
return useDeviceStore((s) => s.removeSavedConnection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the active connection ID
|
||||
*/
|
||||
export function useActiveConnectionId(): ConnectionId | null {
|
||||
return useDeviceStore((s) => s.activeConnectionId);
|
||||
}
|
||||
|
||||
export function useSetActiveConnectionId() {
|
||||
return useDeviceStore((s) => s.setActiveConnectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a specific connection's status
|
||||
*/
|
||||
export function useConnectionStatus(id: ConnectionId): string | undefined {
|
||||
return useDeviceStore((s) => s.savedConnections.find((c) => c.id === id)?.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a device for a specific connection
|
||||
*/
|
||||
export function useDeviceForConnection(id: ConnectionId): Device | undefined {
|
||||
return useDeviceStore((s) => s.getDeviceForConnection(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a connection for a specific device
|
||||
*/
|
||||
export function useConnectionForDevice(deviceId: number): Connection | undefined {
|
||||
return useDeviceStore((s) => s.getConnectionForDevice(deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if any connection is currently connecting
|
||||
*/
|
||||
export function useIsConnecting(): boolean {
|
||||
return useDeviceStore((s) =>
|
||||
s.savedConnections.some((c) => c.status === "connecting" || c.status === "configuring"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get error message for a specific connection
|
||||
*/
|
||||
export function useConnectionError(id: ConnectionId): string | null {
|
||||
return useDeviceStore((s) => s.savedConnections.find((c) => c.id === id)?.error ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all saved connections
|
||||
*/
|
||||
export function useSavedConnections(): Connection[] {
|
||||
return useDeviceStore((s) => s.savedConnections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a connection is connected
|
||||
*/
|
||||
export function useIsConnected(id: ConnectionId): boolean {
|
||||
return useDeviceStore((s) => {
|
||||
const status = s.savedConnections.find((c) => c.id === id)?.status;
|
||||
return status === "connected" || status === "configured";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import type { ValidConfigType, ValidModuleConfigType } from "./changeRegistry.ts";
|
||||
|
||||
interface Dialogs {
|
||||
import: boolean;
|
||||
QR: boolean;
|
||||
shutdown: boolean;
|
||||
reboot: boolean;
|
||||
deviceName: boolean;
|
||||
nodeRemoval: boolean;
|
||||
pkiBackup: boolean;
|
||||
nodeDetails: boolean;
|
||||
unsafeRoles: boolean;
|
||||
refreshKeys: boolean;
|
||||
deleteMessages: boolean;
|
||||
managedMode: boolean;
|
||||
clientNotification: boolean;
|
||||
resetNodeDb: boolean;
|
||||
clearAllStores: boolean;
|
||||
factoryResetDevice: boolean;
|
||||
factoryResetConfig: boolean;
|
||||
}
|
||||
|
||||
type DialogVariant = keyof Dialogs;
|
||||
|
||||
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
|
||||
|
||||
export type ConnectionId = number;
|
||||
export type ConnectionType = "http" | "bluetooth" | "serial";
|
||||
export type ConnectionStatus =
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "disconnected"
|
||||
| "disconnecting"
|
||||
| "configuring"
|
||||
| "configured"
|
||||
| "online"
|
||||
| "error";
|
||||
|
||||
export type Connection = {
|
||||
id: ConnectionId;
|
||||
type: ConnectionType;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastConnectedAt?: number;
|
||||
isDefault?: boolean;
|
||||
status: ConnectionStatus;
|
||||
error?: string;
|
||||
meshDeviceId?: number;
|
||||
} & NewConnection;
|
||||
|
||||
export type NewConnection =
|
||||
| { type: "http"; name: string; url: string }
|
||||
| {
|
||||
type: "bluetooth";
|
||||
name: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
gattServiceUUID?: string;
|
||||
}
|
||||
| {
|
||||
type: "serial";
|
||||
name: string;
|
||||
usbVendorId?: number;
|
||||
usbProductId?: number;
|
||||
};
|
||||
|
||||
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
||||
metadata: {
|
||||
channel: number; // Channel on which the waypoint was received
|
||||
created: Date; // Timestamp when the waypoint was received
|
||||
updated?: Date; // Timestamp when the waypoint was last updated
|
||||
from: number; // Node number of the device that sent the waypoint
|
||||
};
|
||||
};
|
||||
|
||||
export type {
|
||||
Page,
|
||||
Dialogs,
|
||||
DialogVariant,
|
||||
ValidConfigType,
|
||||
ValidModuleConfigType,
|
||||
WaypointWithMetadata,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useDeviceContext } from "@core/hooks/useDeviceContext.ts";
|
||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||
import { type MessageStore, useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
|
||||
import { bindStoreToDevice } from "@core/stores/utils/bindStoreToDevice.ts";
|
||||
|
||||
export {
|
||||
CurrentDeviceContext,
|
||||
type DeviceContext,
|
||||
useDeviceContext,
|
||||
} from "@core/hooks/useDeviceContext";
|
||||
export { useAppStore } from "@core/stores/appStore/index.ts";
|
||||
export { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts";
|
||||
export {
|
||||
useActiveConnection,
|
||||
useActiveConnectionId,
|
||||
useAddSavedConnection,
|
||||
useConnectionError,
|
||||
useConnectionForDevice,
|
||||
useConnectionStatus,
|
||||
useDefaultConnection,
|
||||
useDeviceForConnection,
|
||||
useFirstSavedConnection,
|
||||
useIsConnected,
|
||||
useIsConnecting,
|
||||
useRemoveSavedConnection,
|
||||
useSavedConnections,
|
||||
useUpdateSavedConnection,
|
||||
} from "@core/stores/deviceStore/selectors.ts";
|
||||
export type {
|
||||
Page,
|
||||
ValidConfigType,
|
||||
ValidModuleConfigType,
|
||||
WaypointWithMetadata,
|
||||
} from "@core/stores/deviceStore/types.ts";
|
||||
export {
|
||||
MessageState,
|
||||
type MessageStore,
|
||||
MessageType,
|
||||
useMessageStore,
|
||||
} from "@core/stores/messageStore";
|
||||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
|
||||
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
|
||||
export {
|
||||
SidebarProvider,
|
||||
useSidebar, // TODO: Bring hook into this file
|
||||
} from "@core/stores/sidebarStore/index.tsx";
|
||||
|
||||
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
|
||||
export { clear as clearAllStores } from "idb-keyval";
|
||||
|
||||
// Define hooks to access the stores
|
||||
export const useNodeDB = bindStoreToDevice(
|
||||
useNodeDBStore,
|
||||
(s, deviceId): NodeDB => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId),
|
||||
);
|
||||
|
||||
export const useDevice = (): Device => {
|
||||
const { deviceId } = useDeviceContext();
|
||||
|
||||
const device = useDeviceStore((s) => s.getDevice(deviceId) ?? s.addDevice(deviceId));
|
||||
return device;
|
||||
};
|
||||
|
||||
export const useMessages = (): MessageStore => {
|
||||
const { deviceId } = useDeviceContext();
|
||||
|
||||
const device = useMessageStore((s) => s.getMessageStore(deviceId) ?? s.addMessageStore(deviceId));
|
||||
return device;
|
||||
};
|
||||
@@ -0,0 +1,427 @@
|
||||
import { featureFlags } from "@core/services/featureFlags";
|
||||
import type {
|
||||
ChannelId,
|
||||
ClearMessageParams,
|
||||
ConversationId,
|
||||
GetMessagesParams,
|
||||
Message,
|
||||
MessageId,
|
||||
MessageLogMap,
|
||||
NodeNum,
|
||||
SetMessageStateParams,
|
||||
} from "@core/stores/messageStore/types.ts";
|
||||
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { produce } from "immer";
|
||||
import { create as createStore, type StateCreator } from "zustand";
|
||||
import { type PersistOptions, persist } from "zustand/middleware";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-message-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
const MESSAGESTORE_RETENTION_NUM = 10;
|
||||
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
|
||||
|
||||
export enum MessageState {
|
||||
Ack = "ack",
|
||||
Waiting = "waiting",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
Direct = "direct",
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
|
||||
return [node1, node2].sort((a, b) => a - b).join(":");
|
||||
}
|
||||
|
||||
export interface MessageBuckets {
|
||||
direct: Map<ConversationId, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
}
|
||||
|
||||
type MessageStoreData = {
|
||||
// Persisted data
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
messages: MessageBuckets;
|
||||
drafts: Map<Types.Destination, string>;
|
||||
};
|
||||
|
||||
export interface MessageStore extends MessageStoreData {
|
||||
// Ephemeral state (not persisted)
|
||||
activeChat: number;
|
||||
chatType: MessageType;
|
||||
|
||||
setNodeNum: (nodeNum: number) => void;
|
||||
saveMessage: (message: Message) => void;
|
||||
setMessageState: (params: SetMessageStateParams) => void;
|
||||
getMessages: (params: GetMessagesParams) => Message[];
|
||||
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
//clearAllDrafts: (key: Types.Destination) => void;
|
||||
|
||||
deleteAllMessages: () => void;
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => void;
|
||||
}
|
||||
|
||||
export interface MessageStoreState {
|
||||
addMessageStore: (id: number) => MessageStore;
|
||||
removeMessageStore: (id: number) => void;
|
||||
getMessageStore: (id: number) => MessageStore | undefined;
|
||||
getMessageStores: () => MessageStore[];
|
||||
}
|
||||
interface PrivateMessageStoreState extends MessageStoreState {
|
||||
messageStores: Map<number, MessageStore>;
|
||||
}
|
||||
|
||||
type MessageStorePersisted = {
|
||||
messageStores: Map<number, MessageStoreData>;
|
||||
};
|
||||
|
||||
function messageStoreFactory(
|
||||
id: number,
|
||||
get: () => PrivateMessageStoreState,
|
||||
set: typeof useMessageStore.setState,
|
||||
data?: Partial<MessageStoreData>,
|
||||
): MessageStore {
|
||||
const messages = data?.messages ?? {
|
||||
direct: new Map<ConversationId, MessageLogMap>(),
|
||||
broadcast: new Map<ChannelId, MessageLogMap>(),
|
||||
};
|
||||
const drafts = data?.drafts ?? new Map<Types.Destination, string>();
|
||||
const myNodeNum = data?.myNodeNum;
|
||||
const activeChat = 0;
|
||||
const chatType = MessageType.Broadcast;
|
||||
|
||||
return {
|
||||
id,
|
||||
myNodeNum,
|
||||
messages,
|
||||
drafts,
|
||||
activeChat,
|
||||
chatType,
|
||||
|
||||
setNodeNum: (nodeNum) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const newStore = draft.messageStores.get(id);
|
||||
if (!newStore) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
newStore.myNodeNum = nodeNum;
|
||||
|
||||
for (const [otherId, oldStore] of draft.messageStores) {
|
||||
if (otherId === id || oldStore.myNodeNum !== nodeNum) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Adopt broadcast conversations (reuses inner Map references)
|
||||
for (const [channelId, logMap] of oldStore.messages.broadcast) {
|
||||
newStore.messages.broadcast.set(channelId, logMap);
|
||||
}
|
||||
|
||||
// Adopt direct conversations
|
||||
for (const [conversationId, logMap] of oldStore.messages.direct) {
|
||||
newStore.messages.direct.set(conversationId, logMap);
|
||||
}
|
||||
|
||||
// Adopt drafts
|
||||
for (const [destination, draftText] of oldStore.drafts) {
|
||||
newStore.drafts.set(destination, draftText);
|
||||
}
|
||||
|
||||
// Drop old store
|
||||
draft.messageStores.delete(otherId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
saveMessage: (message: Message) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
let log: MessageLogMap | undefined;
|
||||
if (message.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(message.from, message.to);
|
||||
if (!state.messages.direct.has(conversationId)) {
|
||||
state.messages.direct.set(conversationId, new Map<MessageId, Message>());
|
||||
}
|
||||
|
||||
log = state.messages.direct.get(conversationId);
|
||||
log?.set(message.messageId, message);
|
||||
} else if (message.type === MessageType.Broadcast) {
|
||||
const channelId = message.channel as ChannelId;
|
||||
if (!state.messages.broadcast.has(channelId)) {
|
||||
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
|
||||
}
|
||||
|
||||
log = state.messages.broadcast.get(channelId);
|
||||
log?.set(message.messageId, message);
|
||||
}
|
||||
|
||||
if (log) {
|
||||
// Enforce retention limit
|
||||
evictOldestEntries(log, MESSAGELOG_RETENTION_NUM);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
setMessageState: (params: SetMessageStateParams) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
let messageLog: MessageLogMap | undefined;
|
||||
let targetMessage: Message | undefined;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
messageLog = state.messages.direct.get(conversationId);
|
||||
if (messageLog) {
|
||||
targetMessage = messageLog.get(params.messageId);
|
||||
}
|
||||
} else {
|
||||
// Broadcast
|
||||
messageLog = state.messages.broadcast.get(params.channelId);
|
||||
if (messageLog) {
|
||||
targetMessage = messageLog.get(params.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessage) {
|
||||
targetMessage.state = params.newState ?? MessageState.Ack;
|
||||
} else {
|
||||
console.warn(
|
||||
`Message or conversation/channel not found for state update. Params: ${JSON.stringify(
|
||||
params,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
getMessages: (params: GetMessagesParams): Message[] => {
|
||||
const state = get().messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
let messageMap: MessageLogMap | undefined;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
messageMap = state.messages.direct.get(conversationId);
|
||||
} else {
|
||||
messageMap = state.messages.broadcast.get(params.channelId);
|
||||
}
|
||||
|
||||
if (messageMap === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messagesArray = Array.from(messageMap.values());
|
||||
messagesArray.sort((a, b) => a.date - b.date);
|
||||
return messagesArray;
|
||||
},
|
||||
|
||||
getDraft: (key) => {
|
||||
const state = get().messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
return state.drafts.get(key) ?? "";
|
||||
},
|
||||
setDraft: (key, message) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
state.drafts.set(key, message);
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearDraft: (key) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
state.drafts.delete(key);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
deleteAllMessages: () => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const state = draft.messageStores.get(id);
|
||||
if (!state) {
|
||||
throw new Error(`No MessageStore found for id: ${id}`);
|
||||
}
|
||||
|
||||
let messageLog: MessageLogMap | undefined;
|
||||
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
|
||||
let parentKey: ConversationId | ChannelId;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
parentKey = getConversationId(params.nodeA, params.nodeB);
|
||||
parentMap = state.messages.direct;
|
||||
messageLog = parentMap.get(parentKey);
|
||||
} else {
|
||||
// Broadcast
|
||||
parentKey = params.channelId;
|
||||
parentMap = state.messages.broadcast;
|
||||
messageLog = parentMap.get(parentKey);
|
||||
}
|
||||
|
||||
if (messageLog) {
|
||||
const deleted = messageLog.delete(params.messageId);
|
||||
|
||||
if (deleted) {
|
||||
console.log(
|
||||
`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`,
|
||||
);
|
||||
// Clean up empty MessageLogMap and its entry in the parent map
|
||||
if (messageLog.size === 0) {
|
||||
parentMap.delete(parentKey);
|
||||
console.log(`Cleaned up empty message entry for ${parentKey}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Message entry ${parentKey} not found for message deletion.`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const messageStoreInitializer: StateCreator<PrivateMessageStoreState> = (set, get) => ({
|
||||
messageStores: new Map(),
|
||||
|
||||
addMessageStore: (id) => {
|
||||
const existing = get().messageStores.get(id);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nodeStore = messageStoreFactory(id, get, set);
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
draft.messageStores.set(id, nodeStore);
|
||||
|
||||
// Enforce retention limit
|
||||
evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM);
|
||||
}),
|
||||
);
|
||||
|
||||
return nodeStore;
|
||||
},
|
||||
removeMessageStore: (id) => {
|
||||
set(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
draft.messageStores.delete(id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
getMessageStores: () => Array.from(get().messageStores.values()),
|
||||
getMessageStore: (id) => get().messageStores.get(id),
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateMessageStoreState, MessageStorePersisted> = {
|
||||
name: IDB_KEY_NAME,
|
||||
storage: createStorage<MessageStorePersisted>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): MessageStorePersisted => ({
|
||||
messageStores: new Map(
|
||||
Array.from(s.messageStores.entries()).map(([id, db]) => [
|
||||
id,
|
||||
{
|
||||
id: db.id,
|
||||
myNodeNum: db.myNodeNum,
|
||||
messages: db.messages,
|
||||
drafts: db.drafts,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
"MessageStoreStore: Rehydrating state with ",
|
||||
state.messageStores.size,
|
||||
" MessageStores -",
|
||||
state.messageStores,
|
||||
);
|
||||
|
||||
useMessageStore.setState(
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
const rebuilt = new Map<number, MessageStore>();
|
||||
for (const [id, data] of (
|
||||
draft.messageStores as unknown as Map<number, MessageStoreData>
|
||||
).entries()) {
|
||||
if (data.myNodeNum !== undefined) {
|
||||
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
||||
rebuilt.set(
|
||||
id,
|
||||
messageStoreFactory(id, useMessageStore.getState, useMessageStore.setState, data),
|
||||
);
|
||||
}
|
||||
}
|
||||
draft.messageStores = rebuilt;
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Add persist middleware on the store if the feature flag is enabled
|
||||
const persistMessages = featureFlags.get("persistMessages");
|
||||
console.debug(`MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`);
|
||||
|
||||
export const useMessageStore = persistMessages
|
||||
? createStore<PrivateMessageStoreState, [["zustand/persist", MessageStorePersisted]]>(
|
||||
persist(messageStoreInitializer, persistOptions),
|
||||
)
|
||||
: createStore<PrivateMessageStoreState>()(messageStoreInitializer);
|
||||
@@ -0,0 +1,727 @@
|
||||
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
|
||||
import { Types } from "@meshtastic/core";
|
||||
import { setAutoFreeze } from "immer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getConversationId, MessageState, MessageType } from "./index.ts";
|
||||
import type { ChannelId, Message } from "./types.ts";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||
set: vi.fn((key: string, val: string) => {
|
||||
idbMem.set(key, val);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
idbMem.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
async function freshStore(persist = false) {
|
||||
vi.resetModules();
|
||||
|
||||
// suppress console output from the store during tests (for github actions)
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
// Mock feature flag for persistence
|
||||
vi.doMock("@core/services/featureFlags", () => ({
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => (key === "persistMessages" ? persist : false)),
|
||||
},
|
||||
}));
|
||||
|
||||
const mod = await import("./index.ts");
|
||||
return mod;
|
||||
}
|
||||
|
||||
const myNodeNum = 111;
|
||||
const otherNodeNum1 = 222;
|
||||
const otherNodeNum2 = 333;
|
||||
const broadcastChannel: ChannelId = 0;
|
||||
|
||||
const directMessageToOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum1,
|
||||
channel: 0,
|
||||
date: Date.now(),
|
||||
messageId: 101,
|
||||
state: MessageState.Waiting,
|
||||
message: "Hello other 1 from me",
|
||||
};
|
||||
|
||||
const directMessageFromOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: otherNodeNum1,
|
||||
to: myNodeNum,
|
||||
channel: 0,
|
||||
date: Date.now() + 1000,
|
||||
messageId: 102,
|
||||
state: MessageState.Waiting,
|
||||
message: "Hello me from other 1",
|
||||
};
|
||||
|
||||
const directMessageToOther2: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum2,
|
||||
channel: 0,
|
||||
date: Date.now() + 2000,
|
||||
messageId: 103,
|
||||
state: MessageState.Waiting,
|
||||
message: "Hello other 2 from me",
|
||||
};
|
||||
|
||||
const broadcastMessage1: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: otherNodeNum1,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 3000,
|
||||
messageId: 201,
|
||||
state: MessageState.Waiting,
|
||||
message: "Broadcast message 1",
|
||||
};
|
||||
|
||||
const broadcastMessage2: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: myNodeNum,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 4000,
|
||||
messageId: 202,
|
||||
state: MessageState.Waiting,
|
||||
message: "Broadcast message 2",
|
||||
};
|
||||
|
||||
describe("MessageStore persistence & rehydrate", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should have correct initial state", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
const store = state.addMessageStore(123);
|
||||
|
||||
expect(store.messages.direct).toBeInstanceOf(Map);
|
||||
expect(store.messages.direct.size).toBe(0);
|
||||
expect(store.messages.broadcast).toBeInstanceOf(Map);
|
||||
expect(store.messages.broadcast.size).toBe(0);
|
||||
expect(store.drafts).toBeInstanceOf(Map);
|
||||
expect(store.drafts.size).toBe(0);
|
||||
expect(store.myNodeNum).toBe(undefined);
|
||||
expect(store.activeChat).toBe(0);
|
||||
expect(store.chatType).toBe(MessageType.Broadcast);
|
||||
});
|
||||
|
||||
it("should set nodeNum", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
const db = state.addMessageStore(123);
|
||||
|
||||
db.setNodeNum(myNodeNum);
|
||||
expect(useMessageStore.getState().getMessageStore(123)?.myNodeNum).toBe(myNodeNum);
|
||||
});
|
||||
|
||||
describe("saveMessage", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
it("should save a direct message with correct Map structure", () => {
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
const conversationId = getConversationId(
|
||||
directMessageToOther1.from,
|
||||
directMessageToOther1.to,
|
||||
);
|
||||
|
||||
const store = state.getMessageStore(123)!;
|
||||
|
||||
// Check if the conversation Map exists
|
||||
expect(store.messages.direct.has(conversationId)).toBe(true);
|
||||
const conversationLog = store.messages.direct.get(conversationId);
|
||||
// Check if the inner Map (MessageLogMap) exists and is a Map
|
||||
expect(conversationLog).toBeInstanceOf(Map);
|
||||
// Check if the message exists within the inner Map
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||
// Check the message content
|
||||
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
|
||||
});
|
||||
|
||||
it("should save a broadcast message with correct Map structure", () => {
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
const store = state.getMessageStore(123)!;
|
||||
const channelId = broadcastMessage1.channel;
|
||||
|
||||
expect(store.messages.broadcast.has(channelId)).toBe(true);
|
||||
const channelLog = store.messages.broadcast.get(channelId);
|
||||
expect(channelLog).toBeInstanceOf(Map);
|
||||
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
|
||||
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
|
||||
});
|
||||
|
||||
it("should save multiple messages correctly", () => {
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
|
||||
const store = state.getMessageStore(123)!;
|
||||
|
||||
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
|
||||
expect(store.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(
|
||||
directMessageToOther1,
|
||||
);
|
||||
|
||||
expect(store.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(
|
||||
directMessageFromOther1,
|
||||
);
|
||||
|
||||
const channelId = broadcastMessage1.channel;
|
||||
expect(store.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(
|
||||
broadcastMessage1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessages", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
beforeEach(() => {
|
||||
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther2);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage2);
|
||||
});
|
||||
|
||||
it("should return broadcast messages for a channel, sorted by date", () => {
|
||||
const messages = state.getMessageStore(123)!.getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(broadcastMessage1);
|
||||
expect(messages[1]).toEqual(broadcastMessage2);
|
||||
});
|
||||
|
||||
it("should return empty array for broadcast if channel has no messages", () => {
|
||||
const messages = state.getMessageStore(123)!.getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: Types.ChannelNumber.Channel1,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
|
||||
const messages = state.getMessageStore(123)!.getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
|
||||
it("should return only relevant direct messages for a different chat pair", () => {
|
||||
const messages = state.getMessageStore(123)!.getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum2,
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(directMessageToOther2);
|
||||
});
|
||||
|
||||
it("should return empty array for direct chat if no messages exist", () => {
|
||||
const messages = state.getMessageStore(123)!.getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 999,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMessageState", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
beforeEach(() => {
|
||||
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
});
|
||||
|
||||
it("should update state for a direct message", () => {
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const conversationId = getConversationId(
|
||||
directMessageToOther1.from,
|
||||
directMessageToOther1.to,
|
||||
);
|
||||
const message = useMessageStore
|
||||
.getState()
|
||||
.getMessageStore(123)!
|
||||
.messages.direct.get(conversationId)
|
||||
?.get(directMessageToOther1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it("should update state for another direct message in the same conversation", () => {
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageFromOther1.from,
|
||||
nodeB: directMessageFromOther1.to,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
const conversationId = getConversationId(
|
||||
directMessageFromOther1.from,
|
||||
directMessageFromOther1.to,
|
||||
);
|
||||
const message = useMessageStore
|
||||
.getState()
|
||||
.getMessageStore(123)!
|
||||
.messages.direct.get(conversationId)
|
||||
?.get(directMessageFromOther1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it("should update state for a broadcast message", () => {
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore
|
||||
.getState()
|
||||
.getMessageStore(123)!
|
||||
.messages.broadcast.get(broadcastChannel)
|
||||
?.get(broadcastMessage1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it("should warn if message is not found (direct)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message or conversation/channel not found for state update"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should warn if message is not found (broadcast)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message or conversation/channel not found for state update"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should warn if conversation/channel is not found", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
state.getMessageStore(123)!.setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 998,
|
||||
messageId: 101,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Message or conversation/channel not found for state update"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearMessageByMessageId", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
const extraDirectMessageId = 1011;
|
||||
beforeEach(() => {
|
||||
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
state.getMessageStore(123)?.saveMessage({
|
||||
...directMessageToOther1,
|
||||
messageId: extraDirectMessageId,
|
||||
date: Date.now() + 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete a specific direct message", () => {
|
||||
const messageIdToDelete = directMessageToOther1.messageId;
|
||||
const nodeA = directMessageToOther1.from;
|
||||
const nodeB = directMessageToOther1.to;
|
||||
const conversationId = getConversationId(nodeA, nodeB);
|
||||
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||
const conversationLog = store.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
|
||||
expect(store.messages.direct.has(conversationId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should delete another specific direct message", () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
const nodeA = directMessageFromOther1.from;
|
||||
const nodeB = directMessageFromOther1.to;
|
||||
const conversationId = getConversationId(nodeA, nodeB);
|
||||
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||
const conversationLog = store.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should delete a specific broadcast message", () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
const channelId = broadcastMessage1.channel;
|
||||
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelId,
|
||||
messageId: messageIdToDelete,
|
||||
});
|
||||
|
||||
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||
expect(store.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should clean up empty conversation/channel Maps", () => {
|
||||
const directConvId = getConversationId(
|
||||
directMessageFromOther1.from,
|
||||
directMessageFromOther1.to,
|
||||
);
|
||||
const broadcastChanId = broadcastMessage1.channel;
|
||||
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
});
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageFromOther1.from,
|
||||
nodeB: directMessageFromOther1.to,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
});
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: extraDirectMessageId,
|
||||
});
|
||||
|
||||
expect(state.getMessageStore(123)?.messages.direct.has(directConvId)).toBe(false);
|
||||
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChanId,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
});
|
||||
|
||||
expect(state.getMessageStore(123)?.messages.broadcast.has(broadcastChanId)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not error when trying to delete non-existent message", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
|
||||
|
||||
expect(() => {
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
messageId: 9999,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||
const conversationLog = store.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not found in direct chat"));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not error when trying to delete from non-existent conversation/channel", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(() => {
|
||||
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 9998,
|
||||
messageId: 101,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Drafts", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
const draftKeyDirect = otherNodeNum1;
|
||||
const draftKeyBroadcast = broadcastChannel;
|
||||
const draftMessage = "This is a draft";
|
||||
|
||||
it("should set and get a draft for direct chat", () => {
|
||||
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
|
||||
expect(state.getMessageStore(123)?.drafts.get(draftKeyDirect)).toBe(draftMessage);
|
||||
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe(draftMessage);
|
||||
});
|
||||
|
||||
it("should set and get a draft for broadcast chat", () => {
|
||||
state.getMessageStore(123)?.setDraft(draftKeyBroadcast, draftMessage);
|
||||
expect(state.getMessageStore(123)?.drafts.get(draftKeyBroadcast)).toBe(draftMessage);
|
||||
expect(state.getMessageStore(123)?.getDraft(draftKeyBroadcast)).toBe(draftMessage);
|
||||
});
|
||||
|
||||
it("should return empty string for non-existent draft", () => {
|
||||
expect(state.getMessageStore(123)?.getDraft(999)).toBe("");
|
||||
});
|
||||
|
||||
it("should clear a draft", () => {
|
||||
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
|
||||
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(true);
|
||||
state.getMessageStore(123)?.clearDraft(draftKeyDirect);
|
||||
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(false);
|
||||
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllMessages", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
state.addMessageStore(123);
|
||||
|
||||
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
|
||||
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||
|
||||
expect(state.getMessageStore(123)?.messages.direct.size).toBeGreaterThan(0);
|
||||
expect(state.getMessageStore(123)?.messages.broadcast.size).toBeGreaterThan(0);
|
||||
|
||||
state.getMessageStore(123)?.deleteAllMessages();
|
||||
|
||||
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||
expect(store.messages.direct).toBeInstanceOf(Map);
|
||||
expect(store.messages.direct.size).toBe(0);
|
||||
expect(store.messages.broadcast).toBeInstanceOf(Map);
|
||||
expect(store.messages.broadcast.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence", () => {
|
||||
it("partialize persists data; onRehydrateStorage rebuilds methods (messages + drafts survive)", async () => {
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
const store = state.addMessageStore(123);
|
||||
store.setNodeNum(321);
|
||||
|
||||
const convId = getConversationId(myNodeNum, otherNodeNum1);
|
||||
store.saveMessage(directMessageToOther1);
|
||||
store.saveMessage(broadcastMessage1);
|
||||
store.setDraft(otherNodeNum1 as unknown as Types.Destination, "draft-text");
|
||||
|
||||
const store2 = state.addMessageStore(123);
|
||||
|
||||
expect(store2.messages.direct.has(convId)).toBe(true);
|
||||
expect(store2.messages.direct.get(convId)?.has(101)).toBe(true);
|
||||
expect(store2.messages.broadcast.get(broadcastChannel)?.has(201)).toBe(true);
|
||||
expect(store2.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text");
|
||||
}
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
const store = state.getMessageStore(123)!; // rebuilt instance
|
||||
expect(store).toBeTruthy();
|
||||
|
||||
// Methods should work after rehydrate
|
||||
const directMsgs = store.getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
});
|
||||
expect(directMsgs.map((m) => m.messageId)).toEqual([101]);
|
||||
|
||||
const bMsgs = store.getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel,
|
||||
});
|
||||
expect(bMsgs.map((m) => m.messageId)).toEqual([201]);
|
||||
|
||||
expect(store.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text");
|
||||
|
||||
store.saveMessage(directMessageFromOther1);
|
||||
|
||||
const after = store.getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
});
|
||||
expect(after.map((m) => m.messageId)).toEqual([101, 102]);
|
||||
expect(after[1]?.state).toBe(MessageState.Waiting);
|
||||
}
|
||||
});
|
||||
|
||||
it("removeMessageStore persists removal across reload", async () => {
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
const store = state.addMessageStore(99);
|
||||
|
||||
store.setNodeNum(42);
|
||||
expect(state.getMessageStore(99)).toBeDefined();
|
||||
|
||||
state.removeMessageStore(99);
|
||||
expect(state.getMessageStore(99)).toBeUndefined();
|
||||
}
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.getMessageStore(99)).toBeUndefined(); // still gone
|
||||
}
|
||||
});
|
||||
|
||||
it("rehydrate only rebuilds stores with myNodeNum set (orphans dropped)", async () => {
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
// Orphan (no myNodeNum)
|
||||
const orphan = state.addMessageStore(500);
|
||||
orphan.saveMessage(broadcastMessage1);
|
||||
|
||||
// Proper store
|
||||
const good = state.addMessageStore(501);
|
||||
good.setNodeNum(777);
|
||||
good.saveMessage(broadcastMessage2);
|
||||
}
|
||||
{
|
||||
const { useMessageStore } = await freshStore(true);
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
expect(state.getMessageStore(500)).toBeUndefined(); // orphan dropped
|
||||
const kept = state.getMessageStore(501);
|
||||
expect(kept).toBeDefined();
|
||||
|
||||
expect(kept?.messages.broadcast.get(broadcastChannel)?.has(202)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts the earliest-added message store when exceeding cap of 10", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
state.addMessageStore(i);
|
||||
}
|
||||
// Adding the 11th should evict the earliest (id=1)
|
||||
state.addMessageStore(11);
|
||||
|
||||
expect(state.getMessageStore(1)).toBeUndefined(); // evicted
|
||||
expect(state.getMessageStore(2)).toBeDefined(); // still there
|
||||
expect(state.getMessageStore(11)).toBeDefined(); // newest kept
|
||||
});
|
||||
|
||||
it("keeps only the latest 1000 messages in a broadcast channel (oldest trimmed)", async () => {
|
||||
setAutoFreeze(false); // Disable immer auto-freeze for performance in this test
|
||||
try {
|
||||
const { useMessageStore, MessageType, MessageState } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
const store = state.addMessageStore(123);
|
||||
|
||||
const channelId = 0 as number;
|
||||
const total = 1005;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
store.saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
from: 123,
|
||||
to: 0xffffffff,
|
||||
channel: channelId,
|
||||
date: Date.now() + i,
|
||||
messageId: i,
|
||||
state: MessageState.Waiting,
|
||||
message: `m${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
const fresh = useMessageStore.getState().getMessageStore(123)!;
|
||||
const log = fresh.messages.broadcast.get(channelId)!;
|
||||
|
||||
expect(log.size).toBe(1000); // capped
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(log.has(i)).toBe(false); // oldest removed
|
||||
}
|
||||
for (let i = 6; i <= 1005; i++) {
|
||||
expect(log.has(i)).toBe(true); // newest kept
|
||||
}
|
||||
} finally {
|
||||
setAutoFreeze(true); // Restore immer auto-freeze
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { MessageState, MessageType } from "@core/stores";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
|
||||
type NodeNum = number;
|
||||
type MessageId = number;
|
||||
type ChannelId = Types.ChannelNumber;
|
||||
type ConversationId = string;
|
||||
type MessageLogMap = Map<MessageId, Message>;
|
||||
|
||||
interface MessageBase {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number;
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
type GetMessagesParams =
|
||||
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
|
||||
| { type: MessageType.Broadcast; channelId: ChannelId };
|
||||
|
||||
type SetMessageStateParams =
|
||||
| {
|
||||
type: MessageType.Direct;
|
||||
nodeA: NodeNum;
|
||||
nodeB: NodeNum;
|
||||
messageId: MessageId; // ID of the message within that chat
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
||||
}
|
||||
| {
|
||||
type: MessageType.Broadcast;
|
||||
channelId: ChannelId;
|
||||
messageId: MessageId;
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
||||
};
|
||||
|
||||
type ClearMessageParams =
|
||||
| {
|
||||
type: MessageType.Direct;
|
||||
nodeA: NodeNum;
|
||||
nodeB: NodeNum;
|
||||
messageId: MessageId;
|
||||
}
|
||||
| {
|
||||
type: MessageType.Broadcast;
|
||||
channelId: ChannelId;
|
||||
messageId: MessageId;
|
||||
};
|
||||
|
||||
export type {
|
||||
ChannelId,
|
||||
ClearMessageParams,
|
||||
ConversationId,
|
||||
GetMessagesParams,
|
||||
Message,
|
||||
MessageId,
|
||||
MessageLogMap,
|
||||
NodeNum,
|
||||
SetMessageStateParams,
|
||||
};
|
||||
@@ -0,0 +1,589 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { featureFlags } from "@core/services/featureFlags";
|
||||
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { produce } from "immer";
|
||||
import { create as createStore, type StateCreator } from "zustand";
|
||||
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
|
||||
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
|
||||
|
||||
const IDB_KEY_NAME = "meshtastic-nodedb-store";
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
const NODE_RETENTION_DAYS = 14; // Remove nodes not heard from in 14 days
|
||||
|
||||
type NodeDBData = {
|
||||
// Persisted data
|
||||
id: number;
|
||||
myNodeNum: number | undefined;
|
||||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
};
|
||||
|
||||
export interface NodeDB extends NodeDBData {
|
||||
// Ephemeral state (not persisted)
|
||||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
removeAllNodes: (keepMyNode?: boolean) => void;
|
||||
pruneStaleNodes: () => number;
|
||||
processPacket: (data: ProcessPacketParams) => void;
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addDeviceMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry.DeviceMetrics>) => void;
|
||||
updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
|
||||
updateIgnore: (nodeNum: number, isIgnored: boolean) => void;
|
||||
setNodeNum: (nodeNum: number) => void;
|
||||
setNodeError: (nodeNum: number, error: NodeErrorType) => void;
|
||||
clearNodeError: (nodeNum: number) => void;
|
||||
removeAllNodeErrors: () => void;
|
||||
|
||||
getNodesLength: () => number;
|
||||
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
|
||||
getNodes: (
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
includeSelf?: boolean,
|
||||
) => Protobuf.Mesh.NodeInfo[];
|
||||
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||
hasNodeError: (nodeNum: number) => boolean;
|
||||
}
|
||||
|
||||
export interface nodeDBState {
|
||||
addNodeDB: (id: number) => NodeDB;
|
||||
removeNodeDB: (id: number) => void;
|
||||
getNodeDBs: () => NodeDB[];
|
||||
getNodeDB: (id: number) => NodeDB | undefined;
|
||||
}
|
||||
|
||||
interface PrivateNodeDBState extends nodeDBState {
|
||||
nodeDBs: Map<number, NodeDB>;
|
||||
}
|
||||
|
||||
type NodeDBPersisted = {
|
||||
nodeDBs: Map<number, NodeDBData>;
|
||||
};
|
||||
|
||||
function nodeDBFactory(
|
||||
id: number,
|
||||
get: () => PrivateNodeDBState,
|
||||
set: typeof useNodeDBStore.setState,
|
||||
data?: Partial<NodeDBData>,
|
||||
): NodeDB {
|
||||
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>();
|
||||
const myNodeNum = data?.myNodeNum;
|
||||
|
||||
return {
|
||||
id,
|
||||
myNodeNum,
|
||||
nodeMap,
|
||||
nodeErrors,
|
||||
|
||||
addNode: (node) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
// Check if node already exists
|
||||
const existing = nodeDB.nodeMap.get(node.num);
|
||||
const isNew = !existing;
|
||||
|
||||
// Use validation to check the new node before adding
|
||||
const next = validateIncomingNode(
|
||||
node,
|
||||
(nodeNum: number, err: NodeErrorType) => {
|
||||
nodeDB.setNodeError(nodeNum, err);
|
||||
},
|
||||
(filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => nodeDB.getNodes(filter, true),
|
||||
);
|
||||
|
||||
if (!next) {
|
||||
// Validation failed and error has been set inside validateIncomingNode
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge with existing node data if it exists
|
||||
const merged = existing
|
||||
? {
|
||||
...existing,
|
||||
...next,
|
||||
// Preserve existing fields if new node doesn't have them
|
||||
user: next.user ?? existing.user,
|
||||
position: next.position ?? existing.position,
|
||||
deviceMetrics: next.deviceMetrics ?? existing.deviceMetrics,
|
||||
}
|
||||
: next;
|
||||
|
||||
// Use the validated node's num to ensure consistency
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(merged.num, merged);
|
||||
|
||||
if (isNew) {
|
||||
console.log(
|
||||
`[NodeDB] Adding new node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[NodeDB] Updating existing node from NodeInfo packet: ${merged.num} (${merged.user?.longName || "unknown"})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
removeNode: (nodeNum) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const updated = new Map(nodeDB.nodeMap);
|
||||
updated.delete(nodeNum);
|
||||
nodeDB.nodeMap = updated;
|
||||
}),
|
||||
),
|
||||
|
||||
removeAllNodes: (keepMyNode) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
if (
|
||||
keepMyNode &&
|
||||
nodeDB.myNodeNum !== undefined &&
|
||||
nodeDB.nodeMap.has(nodeDB.myNodeNum)
|
||||
) {
|
||||
newNodeMap.set(
|
||||
nodeDB.myNodeNum,
|
||||
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? create(Protobuf.Mesh.NodeInfoSchema),
|
||||
);
|
||||
}
|
||||
nodeDB.nodeMap = newNodeMap;
|
||||
}),
|
||||
),
|
||||
|
||||
pruneStaleNodes: () => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cutoffSec = nowSec - NODE_RETENTION_DAYS * 24 * 60 * 60;
|
||||
let prunedCount = 0;
|
||||
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
|
||||
|
||||
for (const [nodeNum, node] of nodeDB.nodeMap) {
|
||||
// Keep myNode regardless of lastHeard
|
||||
// Keep nodes that have been heard recently
|
||||
// Keep nodes without lastHeard (just in case)
|
||||
if (nodeNum === nodeDB.myNodeNum || !node.lastHeard || node.lastHeard >= cutoffSec) {
|
||||
newNodeMap.set(nodeNum, node);
|
||||
} else {
|
||||
prunedCount++;
|
||||
console.log(
|
||||
`[NodeDB] Pruning stale node ${nodeNum} (last heard ${Math.floor((nowSec - node.lastHeard) / 86400)} days ago)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
nodeDB.nodeMap = newNodeMap;
|
||||
}),
|
||||
);
|
||||
|
||||
if (prunedCount > 0) {
|
||||
console.log(
|
||||
`[NodeDB] Pruned ${prunedCount} stale node(s) older than ${NODE_RETENTION_DAYS} days`,
|
||||
);
|
||||
}
|
||||
|
||||
return prunedCount;
|
||||
},
|
||||
|
||||
setNodeError: (nodeNum, error) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
nodeDB.nodeErrors = new Map(nodeDB.nodeErrors).set(nodeNum, {
|
||||
node: nodeNum,
|
||||
error,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
clearNodeError: (nodeNum) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const updated = new Map(nodeDB.nodeErrors);
|
||||
updated.delete(nodeNum);
|
||||
nodeDB.nodeErrors = updated;
|
||||
}),
|
||||
),
|
||||
|
||||
removeAllNodeErrors: () =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
nodeDB.nodeErrors = new Map<number, NodeError>();
|
||||
}),
|
||||
),
|
||||
|
||||
processPacket: (data) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const node = nodeDB.nodeMap.get(data.from);
|
||||
const nowSec = Math.floor(Date.now() / 1000); // lastHeard is in seconds(!)
|
||||
|
||||
if (node) {
|
||||
const updated = {
|
||||
...node,
|
||||
lastHeard: data.time > 0 ? data.time : nowSec,
|
||||
snr: data.snr,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(data.from, updated);
|
||||
} else {
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(
|
||||
data.from,
|
||||
create(Protobuf.Mesh.NodeInfoSchema, {
|
||||
num: data.from,
|
||||
lastHeard: data.time > 0 ? data.time : nowSec, // fallback to now if time is 0 or negative,
|
||||
snr: data.snr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
addUser: (user) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const current = nodeDB.nodeMap.get(user.from);
|
||||
const isNew = !current;
|
||||
const updated = {
|
||||
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||
user: user.data,
|
||||
num: user.from,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(user.from, updated);
|
||||
|
||||
if (isNew) {
|
||||
console.log(
|
||||
`[NodeDB] Adding new node from user packet: ${user.from} (${user.data.longName || "unknown"})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
addPosition: (position) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const current = nodeDB.nodeMap.get(position.from);
|
||||
const isNew = !current;
|
||||
const updated = {
|
||||
...(current ?? create(Protobuf.Mesh.NodeInfoSchema)),
|
||||
position: position.data,
|
||||
num: position.from,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(position.from, updated);
|
||||
|
||||
if (isNew) {
|
||||
console.log(`[NodeDB] Adding new node from position packet: ${position.from}`);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
addDeviceMetrics: (metrics) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const current = nodeDB.nodeMap.get(metrics.from);
|
||||
// Only fold live device metrics into nodes we already know about, to
|
||||
// avoid creating identity-less phantom nodes from a stray telemetry packet.
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
...current,
|
||||
deviceMetrics: metrics.data,
|
||||
num: metrics.from,
|
||||
};
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(metrics.from, updated);
|
||||
}),
|
||||
),
|
||||
|
||||
setNodeNum: (nodeNum) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const newDB = draft.nodeDBs.get(id);
|
||||
if (!newDB) {
|
||||
throw new Error(`No nodeDB found for id: ${id}`);
|
||||
}
|
||||
|
||||
newDB.myNodeNum = nodeNum;
|
||||
|
||||
for (const [key, oldDB] of draft.nodeDBs) {
|
||||
if (key === id) {
|
||||
// short-circuit self
|
||||
continue;
|
||||
}
|
||||
if (oldDB.myNodeNum === nodeNum) {
|
||||
// We found the oldDB (same myNodeNum). Merge node-by-node as if the new nodes are added with addNode
|
||||
|
||||
const mergedNodes = new Map(oldDB.nodeMap);
|
||||
const mergedErrors = new Map(oldDB.nodeErrors);
|
||||
|
||||
const getNodesProxy = (
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
): Protobuf.Mesh.NodeInfo[] => {
|
||||
const arr = Array.from(mergedNodes.values());
|
||||
return filter ? arr.filter(filter) : arr;
|
||||
};
|
||||
|
||||
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
|
||||
mergedErrors.set(nodeNum, {
|
||||
node: nodeNum,
|
||||
error: err,
|
||||
});
|
||||
};
|
||||
|
||||
for (const [num, newNode] of newDB.nodeMap) {
|
||||
const next = validateIncomingNode(newNode, setErrorProxy, getNodesProxy);
|
||||
if (next) {
|
||||
mergedNodes.set(num, next);
|
||||
}
|
||||
|
||||
const err = newDB.getNodeError(num);
|
||||
if (err && !oldDB.hasNodeError(num)) {
|
||||
mergedErrors.set(num, err);
|
||||
}
|
||||
}
|
||||
|
||||
// finalize: move maps into newDB and drop oldDB entry
|
||||
newDB.nodeMap = mergedNodes;
|
||||
newDB.nodeErrors = mergedErrors;
|
||||
draft.nodeDBs.delete(oldDB.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
updateFavorite: (nodeNum, isFavorite) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const node = nodeDB.nodeMap.get(nodeNum);
|
||||
if (node) {
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
|
||||
...node,
|
||||
isFavorite: isFavorite,
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
updateIgnore: (nodeNum, isIgnored) =>
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const nodeDB = draft.nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
|
||||
const node = nodeDB.nodeMap.get(nodeNum);
|
||||
if (node) {
|
||||
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(nodeNum, {
|
||||
...node,
|
||||
isIgnored: isIgnored,
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
getNodesLength: () => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
return nodeDB.nodeMap.size;
|
||||
},
|
||||
|
||||
getNode: (nodeNum) => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
return nodeDB.nodeMap.get(nodeNum);
|
||||
},
|
||||
|
||||
getNodes: (filter, includeSelf) => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
const all = Array.from(nodeDB.nodeMap.values()).filter((n) =>
|
||||
includeSelf ? true : n.num !== nodeDB.myNodeNum,
|
||||
);
|
||||
|
||||
return filter ? all.filter(filter) : all;
|
||||
},
|
||||
|
||||
getMyNode: () => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
if (nodeDB.myNodeNum) {
|
||||
return nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? create(Protobuf.Mesh.NodeInfoSchema);
|
||||
}
|
||||
},
|
||||
|
||||
getNodeError: (nodeNum) => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
return nodeDB.nodeErrors.get(nodeNum);
|
||||
},
|
||||
|
||||
hasNodeError: (nodeNum) => {
|
||||
const nodeDB = get().nodeDBs.get(id);
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
return nodeDB.nodeErrors.has(nodeNum);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (set, get) => ({
|
||||
nodeDBs: new Map(),
|
||||
|
||||
addNodeDB: (id) => {
|
||||
const existing = get().nodeDBs.get(id);
|
||||
if (existing) {
|
||||
// Prune stale nodes when accessing existing nodeDB
|
||||
existing.pruneStaleNodes();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const nodeDB = nodeDBFactory(id, get, set);
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
draft.nodeDBs = new Map(draft.nodeDBs).set(id, nodeDB);
|
||||
}),
|
||||
);
|
||||
|
||||
// Prune stale nodes on creation (useful when rehydrating from storage)
|
||||
nodeDB.pruneStaleNodes();
|
||||
|
||||
return nodeDB;
|
||||
},
|
||||
removeNodeDB: (id) => {
|
||||
set(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const updated = new Map(draft.nodeDBs);
|
||||
updated.delete(id);
|
||||
draft.nodeDBs = updated;
|
||||
}),
|
||||
);
|
||||
},
|
||||
getNodeDBs: () => Array.from(get().nodeDBs.values()),
|
||||
getNodeDB: (id) => get().nodeDBs.get(id),
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
|
||||
name: IDB_KEY_NAME,
|
||||
storage: createStorage<NodeDBPersisted>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): NodeDBPersisted => ({
|
||||
nodeDBs: new Map(
|
||||
Array.from(s.nodeDBs.entries()).map(([id, db]) => [
|
||||
id,
|
||||
{
|
||||
id: db.id,
|
||||
myNodeNum: db.myNodeNum,
|
||||
nodeMap: db.nodeMap,
|
||||
nodeErrors: db.nodeErrors,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
"NodeDBStore: Rehydrating state with ",
|
||||
state.nodeDBs.size,
|
||||
" nodeDBs -",
|
||||
state.nodeDBs,
|
||||
);
|
||||
|
||||
useNodeDBStore.setState(
|
||||
produce<PrivateNodeDBState>((draft) => {
|
||||
const rebuilt = new Map<number, NodeDB>();
|
||||
for (const [id, data] of (draft.nodeDBs as unknown as Map<number, NodeDBData>).entries()) {
|
||||
if (data.myNodeNum !== undefined) {
|
||||
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
||||
rebuilt.set(
|
||||
id,
|
||||
nodeDBFactory(id, useNodeDBStore.getState, useNodeDBStore.setState, data),
|
||||
);
|
||||
}
|
||||
}
|
||||
draft.nodeDBs = rebuilt;
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Add persist middleware on the store if the feature flag is enabled
|
||||
const persistNodes = featureFlags.get("persistNodeDB");
|
||||
console.debug(`NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`);
|
||||
|
||||
export const useNodeDBStore = persistNodes
|
||||
? createStore(subscribeWithSelector(persist(nodeDBInitializer, persistOptions)))
|
||||
: createStore(subscribeWithSelector(nodeDBInitializer));
|
||||
@@ -0,0 +1,34 @@
|
||||
import { vi } from "vitest";
|
||||
import type { NodeDB } from "./index.ts";
|
||||
|
||||
/**
|
||||
* You can spread this base mock in your tests and override only the
|
||||
* properties relevant to a specific test case.
|
||||
*/
|
||||
export const mockNodeDBStore: NodeDB = {
|
||||
id: 0,
|
||||
myNodeNum: 0,
|
||||
nodeErrors: new Map(),
|
||||
nodeMap: new Map(),
|
||||
|
||||
addNode: vi.fn(),
|
||||
addUser: vi.fn(),
|
||||
addPosition: vi.fn(),
|
||||
addDeviceMetrics: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
removeAllNodes: vi.fn(),
|
||||
pruneStaleNodes: vi.fn().mockReturnValue(0),
|
||||
processPacket: vi.fn(),
|
||||
setNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
removeAllNodeErrors: vi.fn(),
|
||||
getNodeError: vi.fn().mockReturnValue(undefined),
|
||||
hasNodeError: vi.fn().mockReturnValue(false),
|
||||
getNodes: vi.fn().mockReturnValue([]),
|
||||
getNodesLength: vi.fn().mockReturnValue(0),
|
||||
getNode: vi.fn().mockReturnValue(undefined),
|
||||
getMyNode: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
updateIgnore: vi.fn(),
|
||||
setNodeNum: vi.fn(),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||
set: vi.fn((key: string, val: string) => {
|
||||
idbMem.set(key, val);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
idbMem.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
// Load a fresh, non-persisting copy of the nodeDB store.
|
||||
async function freshNodeDB() {
|
||||
vi.resetModules();
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
vi.doMock("@core/services/featureFlags", () => ({
|
||||
featureFlags: { get: vi.fn(() => false) },
|
||||
}));
|
||||
|
||||
const { useNodeDBStore } = await import("./index.ts");
|
||||
return useNodeDBStore;
|
||||
}
|
||||
|
||||
function metricsPacket(
|
||||
from: number,
|
||||
data: Protobuf.Telemetry.DeviceMetrics,
|
||||
): Types.PacketMetadata<Protobuf.Telemetry.DeviceMetrics> {
|
||||
return { id: 1, rxTime: new Date(), type: "broadcast", from, to: 0, channel: 0, data };
|
||||
}
|
||||
|
||||
describe("nodeDBStore – addDeviceMetrics", () => {
|
||||
it("folds live device metrics into an existing node", async () => {
|
||||
const useNodeDBStore = await freshNodeDB();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
db.addNode(create(Protobuf.Mesh.NodeInfoSchema, { num: 5 }));
|
||||
|
||||
db.addDeviceMetrics(
|
||||
metricsPacket(
|
||||
5,
|
||||
create(Protobuf.Telemetry.DeviceMetricsSchema, { batteryLevel: 80, voltage: 4.1 }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(db.getNode(5)?.deviceMetrics?.batteryLevel).toBe(80);
|
||||
expect(db.getNode(5)?.deviceMetrics?.voltage).toBeCloseTo(4.1);
|
||||
});
|
||||
|
||||
it("ignores metrics for an unknown node (no phantom node is created)", async () => {
|
||||
const useNodeDBStore = await freshNodeDB();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
|
||||
db.addDeviceMetrics(
|
||||
metricsPacket(999, create(Protobuf.Telemetry.DeviceMetricsSchema, { batteryLevel: 50 })),
|
||||
);
|
||||
|
||||
expect(db.getNode(999)).toBeUndefined();
|
||||
expect(db.getNodesLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,549 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const idbMem = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||
set: vi.fn((key: string, val: string) => {
|
||||
idbMem.set(key, val);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
idbMem.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
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)
|
||||
async function freshStore(persist = false) {
|
||||
vi.resetModules();
|
||||
|
||||
// suppress console output from the store during tests (for github actions)
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
vi.doMock("@core/services/featureFlags", () => ({
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => (key === "persistNodeDB" ? persist : false)),
|
||||
},
|
||||
}));
|
||||
|
||||
const storeMod = await import("./index.ts");
|
||||
const { useNodeDB } = await import("../index.ts");
|
||||
return { ...storeMod, useNodeDB };
|
||||
}
|
||||
|
||||
function makeNode(num: number, extras: Record<string, any> = {}) {
|
||||
return create(Protobuf.Mesh.NodeInfoSchema, { num, ...extras });
|
||||
}
|
||||
function makeUser(fields: Record<string, any>) {
|
||||
return create(Protobuf.Mesh.UserSchema, fields);
|
||||
}
|
||||
function makePosition(fields: Record<string, any>) {
|
||||
return create(Protobuf.Mesh.PositionSchema, fields);
|
||||
}
|
||||
|
||||
describe("NodeDB store", () => {
|
||||
beforeEach(() => {
|
||||
idbMem.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
|
||||
const db1 = useNodeDBStore.getState().addNodeDB(123);
|
||||
const db2 = useNodeDBStore.getState().addNodeDB(123);
|
||||
expect(db1).toStrictEqual(db2);
|
||||
|
||||
const got = useNodeDBStore.getState().getNodeDB(123);
|
||||
expect(got).toStrictEqual(db1);
|
||||
|
||||
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
|
||||
});
|
||||
|
||||
it("addNode, getNode(s), getNodesLength, removeNode", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
|
||||
db.addNode(makeNode(10));
|
||||
db.addNode(makeNode(11));
|
||||
expect(db.getNodesLength()).toBe(2);
|
||||
expect(db.getNode(10)?.num).toBe(10);
|
||||
|
||||
const all = db.getNodes();
|
||||
expect(all.map((n) => n.num).sort()).toEqual([10, 11]);
|
||||
|
||||
db.removeNode(10);
|
||||
expect(db.getNodesLength()).toBe(1);
|
||||
expect(db.getNode(10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("processPacket creates or updates a node", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
|
||||
db.processPacket({ from: 50, time: 1111, snr: 7 } as any);
|
||||
expect(db.getNode(50)).toBeTruthy();
|
||||
expect(db.getNode(50)?.lastHeard).toBe(1111);
|
||||
expect(db.getNode(50)?.snr).toBe(7);
|
||||
|
||||
db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
|
||||
expect(db.getNode(50)?.lastHeard).toBe(2222);
|
||||
expect(db.getNode(50)?.snr).toBe(9);
|
||||
|
||||
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
|
||||
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now() / 1000, -1); // within 1s, note lastHeard is in seconds
|
||||
expect(db.getNode(50)?.snr).toBe(9);
|
||||
});
|
||||
|
||||
it("addUser and addPosition updates existing or creates new nodes", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
|
||||
// addUser creates node if missing
|
||||
db.addUser({ from: 77, data: { id: "u" } } as any);
|
||||
expect(db.getNode(77)?.user).toEqual({ id: "u" });
|
||||
|
||||
// addPosition updates same node
|
||||
db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any);
|
||||
expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 });
|
||||
expect(db.getNode(77)?.num).toBe(77);
|
||||
});
|
||||
|
||||
it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
|
||||
db.setNodeError(10, "BadFoo" as any);
|
||||
expect(db.hasNodeError(10)).toBe(true);
|
||||
expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" });
|
||||
|
||||
db.clearNodeError(10);
|
||||
expect(db.hasNodeError(10)).toBe(false);
|
||||
expect(db.getNodeError(10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getMyNode returns undefined before setNodeNum; works after", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
db.addNode(makeNode(123));
|
||||
|
||||
expect(db.getMyNode()).toBeUndefined();
|
||||
db.setNodeNum(123);
|
||||
|
||||
const me = db.getMyNode();
|
||||
expect(me?.num).toBe(123);
|
||||
});
|
||||
|
||||
it("setNodeNum merges with existing DB with same myNodeNum", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(10);
|
||||
oldDB.setNodeNum(999);
|
||||
oldDB.addNode(makeNode(200));
|
||||
oldDB.setNodeError(200, "ERROR" as any);
|
||||
|
||||
const newDB = st.addNodeDB(11);
|
||||
// newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
|
||||
newDB.setNodeNum(999);
|
||||
|
||||
expect(st.getNodeDB(10)).toBeUndefined();
|
||||
expect(st.getNodeDB(11)).toBeDefined();
|
||||
expect(newDB.getNode(200)).toBeTruthy();
|
||||
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
|
||||
});
|
||||
|
||||
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
const db = st.addNodeDB(123);
|
||||
db.setNodeNum(321);
|
||||
db.addNode(makeNode(50));
|
||||
db.setNodeError(50, "ERROR" as any);
|
||||
}
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
const db = st.getNodeDB(123)!;
|
||||
|
||||
// methods should work after rehydrate
|
||||
expect(db.getNode(50)?.num).toBe(50);
|
||||
expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" });
|
||||
db.addNode(makeNode(51));
|
||||
expect(db.getNode(51)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("getNodes applies filter and excludes myNodeNum", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
db.setNodeNum(11);
|
||||
db.addNode(makeNode(10));
|
||||
db.addNode(makeNode(11));
|
||||
db.addNode(makeNode(12));
|
||||
|
||||
const all = db.getNodes();
|
||||
expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
|
||||
|
||||
const filtered = db.getNodes((n) => n.num > 10);
|
||||
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
|
||||
});
|
||||
|
||||
it("will prune nodes after 14 days of inactivitiy", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
st.addNodeDB(1).addNode(makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 })); // 15 days ago
|
||||
st.addNodeDB(1).addNode(makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 })); // 7 days ago
|
||||
|
||||
st.getNodeDB(1)!.pruneStaleNodes();
|
||||
expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
|
||||
});
|
||||
|
||||
it("removeNodeDB persists removal across reload", async () => {
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
st.addNodeDB(99);
|
||||
expect(st.getNodeDB(99)).toBeDefined();
|
||||
st.removeNodeDB(99);
|
||||
expect(st.getNodeDB(99)).toBeUndefined();
|
||||
}
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
|
||||
}
|
||||
});
|
||||
|
||||
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const orphan = st.addNodeDB(500); // no setNodeNum
|
||||
orphan.addNode(makeNode(1));
|
||||
|
||||
const good = st.addNodeDB(501);
|
||||
good.setNodeNum(42);
|
||||
good.addNode(makeNode(2));
|
||||
}
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
|
||||
expect(st.getNodeDB(501)).toBeDefined(); // kept
|
||||
expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("methods throw after their DB is removed from the map", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
const db = st.addNodeDB(800);
|
||||
|
||||
st.removeNodeDB(800);
|
||||
|
||||
expect(() => db.getNodesLength()).toThrow(/No nodeDB found/);
|
||||
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
||||
const keyOld = toByteArray("40g5tLC6A+tXE92EyhwVwdiKsXwa1QUjZjkzEi0pCy4=");
|
||||
const keyNew = toByteArray("osxYoEP43oDeWZyjyKx1wz/5cvwEOthHB6AhO2fXEQg=");
|
||||
|
||||
it("upserts node", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(10);
|
||||
oldDB.setNodeNum(999);
|
||||
oldDB.addNode(makeNode(200, { position: { altitude: 100 } }));
|
||||
|
||||
const newDB = st.addNodeDB(11);
|
||||
newDB.addNode(makeNode(300));
|
||||
newDB.addNode(makeNode(200, { position: { altitude: 120 } }));
|
||||
newDB.setNodeNum(999);
|
||||
|
||||
expect(st.getNodeDB(10)).toBeUndefined(); // old db removed
|
||||
expect(newDB.getNode(300)).toBeTruthy(); // node kept
|
||||
const n200 = newDB.getNode(200)!;
|
||||
expect(n200.position?.altitude).toBe(120); // replace existing
|
||||
});
|
||||
|
||||
it("key conflict: keep old node, flag error", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(20);
|
||||
oldDB.setNodeNum(42);
|
||||
oldDB.addNode(
|
||||
makeNode(7, {
|
||||
user: makeUser({ publicKey: keyOld, longName: "old-7" }),
|
||||
position: makePosition({ latitudeI: 11, longitudeI: 22 }),
|
||||
}),
|
||||
);
|
||||
const newDB = st.addNodeDB(21);
|
||||
newDB.addNode(
|
||||
makeNode(7, {
|
||||
user: makeUser({ publicKey: keyNew, longName: "new-7" }),
|
||||
position: makePosition({ latitudeI: 33 }),
|
||||
}),
|
||||
);
|
||||
newDB.setNodeNum(42);
|
||||
|
||||
const n7 = newDB.getNode(7)!;
|
||||
|
||||
// node from old
|
||||
expect(n7.user?.longName).toBe("old-7");
|
||||
expect(n7.user?.publicKey).toEqual(keyOld);
|
||||
expect(n7.position?.latitudeI).toBe(11);
|
||||
expect(n7.position?.longitudeI).toBe(22);
|
||||
|
||||
// error flagged
|
||||
const err = newDB.getNodeError(7);
|
||||
expect(err).toBeTruthy();
|
||||
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
|
||||
});
|
||||
|
||||
it("empty new key; drop new node", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(30);
|
||||
oldDB.setNodeNum(77);
|
||||
oldDB.addNode(makeNode(5, { user: { publicKey: keyOld, longName: "old-5" } }));
|
||||
|
||||
const newDB = st.addNodeDB(31);
|
||||
newDB.addNode(makeNode(5, { user: { publicKey: new Uint8Array(), longName: "new-5" } }));
|
||||
|
||||
newDB.setNodeNum(77);
|
||||
|
||||
// node from old
|
||||
const n5 = newDB.getNode(5)!;
|
||||
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
|
||||
expect(n5.user?.longName).toBe("old-5");
|
||||
|
||||
// error not flagged; dropped silently
|
||||
const err = newDB!.getNodeError(5);
|
||||
expect(err).toBeUndefined();
|
||||
});
|
||||
|
||||
it("old key empty, new key present, store new node", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(40);
|
||||
oldDB.setNodeNum(1001);
|
||||
oldDB.addNode(makeNode(8, { user: { longName: "old-8" } })); // no key
|
||||
|
||||
const newDB = st.addNodeDB(41);
|
||||
newDB.addNode(
|
||||
makeNode(8, {
|
||||
user: { publicKey: keyNew, longName: "new-8" },
|
||||
position: { altitude: 555 },
|
||||
}),
|
||||
);
|
||||
|
||||
newDB.setNodeNum(1001);
|
||||
|
||||
// node from new
|
||||
const n8 = newDB.getNode(8)!;
|
||||
expect(n8.user?.longName).toBe("new-8");
|
||||
expect(n8.user?.publicKey).toEqual(keyNew);
|
||||
expect(n8.position?.altitude).toBe(555);
|
||||
|
||||
// no error
|
||||
const err = newDB.getNodeError(8);
|
||||
expect(err).toBeFalsy();
|
||||
});
|
||||
|
||||
it("unions nodeErrors: preserves old and new, respects existing-on-conflict", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(50);
|
||||
oldDB.setNodeNum(2020);
|
||||
oldDB.addNode(makeNode(1, { user: { longName: "old-1" } }));
|
||||
oldDB.setNodeError(1, "OLD_ERR" as any);
|
||||
|
||||
const newDB = st.addNodeDB(51);
|
||||
newDB.addNode(makeNode(1, { user: { longName: "new-1" } }));
|
||||
newDB.addNode(makeNode(2, { user: { longName: "new-2" } }));
|
||||
newDB.setNodeError(2, "NEW_ERR" as any);
|
||||
|
||||
// also set overlapping error
|
||||
newDB.setNodeError(1, "SHOULD_NOT_OVERWRITE" as any);
|
||||
|
||||
newDB.setNodeNum(2020);
|
||||
|
||||
expect(newDB.getNodeError(1)!.error).toBe("OLD_ERR"); // old kept
|
||||
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
|
||||
});
|
||||
|
||||
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
const db = st.addNodeDB(1000);
|
||||
db.setNodeNum(55);
|
||||
db.addNode(makeNode(55, { user: { longName: "me" } }));
|
||||
db.addNode(makeNode(56));
|
||||
db.setNodeError(56, "ERR" as any);
|
||||
db.removeAllNodes(true);
|
||||
db.removeAllNodeErrors();
|
||||
}
|
||||
{
|
||||
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||
const st = useNodeDBStore.getState();
|
||||
const db = st.getNodeDB(1000)!;
|
||||
expect(db.getNode(55)).toBeTruthy(); // kept me
|
||||
expect(db.getNode(56)).toBeUndefined(); // cleared others
|
||||
expect(db.getNodeError(56)).toBeUndefined(); // cleared errors
|
||||
}
|
||||
});
|
||||
|
||||
it("getMyNode works after merge establishes myNodeNum", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const st = useNodeDBStore.getState();
|
||||
|
||||
const oldDB = st.addNodeDB(1100);
|
||||
oldDB.setNodeNum(4242);
|
||||
oldDB.addNode(makeNode(4242));
|
||||
|
||||
const newDB = st.addNodeDB(1101);
|
||||
newDB.setNodeNum(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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { NodeErrorType } from "@core/stores";
|
||||
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.
|
||||
// If valid, returns a node to store, else returns undefined.
|
||||
export function validateIncomingNode(
|
||||
newNode: Protobuf.Mesh.NodeInfo,
|
||||
setNodeError: (nodeNum: number, error: NodeErrorType) => void,
|
||||
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => Protobuf.Mesh.NodeInfo[],
|
||||
): Protobuf.Mesh.NodeInfo | undefined {
|
||||
const num = newNode.num;
|
||||
const existingNodes = getNodes((node) => node.num === num);
|
||||
|
||||
if (existingNodes.length === 0) {
|
||||
// No existing node with this node number.
|
||||
// Check if the new node's public key (if present and not empty)
|
||||
// is already claimed by another existing node.
|
||||
if (newNode.user?.publicKey !== undefined && newNode.user?.publicKey.length > 0) {
|
||||
const nodesWithSameKey = getNodes((node) => node.user?.publicKey === newNode.user?.publicKey);
|
||||
if (nodesWithSameKey.length > 0) {
|
||||
// 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");
|
||||
return undefined; // drop newNode entirely
|
||||
}
|
||||
}
|
||||
return newNode; // No conflicts, accept newNode
|
||||
} else if (existingNodes.length === 1) {
|
||||
// One existing node with this node number.
|
||||
const oldNode = existingNodes[0];
|
||||
if (!oldNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A public key is considered matching if the incoming key equals
|
||||
// the existing key, OR if the existing key is empty.
|
||||
const isKeyMatchingOrExistingEmpty =
|
||||
equalKey(oldNode.user?.publicKey, newNode.user?.publicKey) ||
|
||||
oldNode.user?.publicKey === undefined ||
|
||||
oldNode.user?.publicKey.length === 0;
|
||||
|
||||
if (isKeyMatchingOrExistingEmpty) {
|
||||
// Keys match or existing key was empty: trust the incoming node data completely.
|
||||
// This allows for legitimate updates to user info and other fields.
|
||||
return newNode;
|
||||
} 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.
|
||||
setNodeError(num, "MISMATCH_PKI");
|
||||
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 {
|
||||
// Multiple existing nodes with the same node number
|
||||
// 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");
|
||||
return undefined; // drop newNode entirely
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI" | "DUPLICATE_PKI";
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
error: NodeErrorType;
|
||||
};
|
||||
|
||||
type ProcessPacketParams = {
|
||||
from: number;
|
||||
snr: number;
|
||||
time: number;
|
||||
};
|
||||
|
||||
export type { NodeError, ProcessPacketParams, NodeErrorType };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
interface SidebarContextProps {
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
|
||||
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
|
||||
const toggleSidebar = useMemo(
|
||||
() => () => {
|
||||
setIsCollapsed((prev) => !prev);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[isCollapsed, toggleSidebar],
|
||||
);
|
||||
|
||||
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSidebar = (): SidebarContextProps => {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[deviceId, 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();
|
||||
};
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storeSelector, equality, wait, fireImmediately]);
|
||||
|
||||
const getSnapshot = () => snapRef.current;
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
return useBound;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function evictOldestEntries<T>(arr: T[], maxSize: number): void;
|
||||
export function evictOldestEntries<K, V>(map: Map<K, V>, maxSize: number): void;
|
||||
|
||||
export function evictOldestEntries<T, K, V>(collection: T[] | Map<K, V>, maxSize: number): void {
|
||||
if (Array.isArray(collection)) {
|
||||
// Trim array from the front (assuming oldest entries are at the start)
|
||||
while (collection.length > maxSize) {
|
||||
collection.shift();
|
||||
}
|
||||
} else if (collection instanceof Map) {
|
||||
// Trim map by insertion order
|
||||
while (collection.size > maxSize) {
|
||||
const firstKey = collection.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
collection.delete(firstKey);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as idb from "idb-keyval";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorage } from "./indexDB.ts";
|
||||
|
||||
type PersistStorage<T> = ReturnType<typeof createStorage<T>>;
|
||||
|
||||
describe("indexDB.ts persistence (steps 1–5)", () => {
|
||||
let store: PersistStorage<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
store = createStorage<any>();
|
||||
});
|
||||
|
||||
async function roundTrip(obj: any) {
|
||||
const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
|
||||
const getSpy = vi.spyOn(idb, "get");
|
||||
await store.setItem("rt", obj);
|
||||
const storedStr = (setSpy.mock.calls[0] as any[])[1] as string;
|
||||
getSpy.mockResolvedValue(storedStr);
|
||||
return await store.getItem("rt");
|
||||
}
|
||||
|
||||
// Basic methods
|
||||
it("getItem returns null when idb-keyval.get yields undefined", async () => {
|
||||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
|
||||
const res = await store.getItem("missing-key");
|
||||
expect(getSpy).toHaveBeenCalledWith("missing-key");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("setItem writes a string via idb-keyval.set", async () => {
|
||||
const setSpy = vi.spyOn(idb, "set").mockResolvedValue();
|
||||
const payload = { state: { a: 1 }, version: 0 };
|
||||
await store.setItem("k1", payload);
|
||||
expect(setSpy).toHaveBeenCalledTimes(1);
|
||||
const [, value] = setSpy.mock.calls[0]!;
|
||||
expect(typeof value).toBe("string");
|
||||
// sanity: it should be JSON
|
||||
expect(() => JSON.parse(value as string)).not.toThrow();
|
||||
});
|
||||
|
||||
it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => {
|
||||
const delSpy = vi.spyOn(idb, "del").mockResolvedValue();
|
||||
await store.removeItem("k2");
|
||||
expect(delSpy).toHaveBeenCalledWith("k2");
|
||||
|
||||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined);
|
||||
const res = await store.getItem("k2");
|
||||
expect(getSpy).toHaveBeenCalledWith("k2");
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
// Map
|
||||
it("round-trips an empty Map", async () => {
|
||||
const out = await roundTrip({ state: { m: new Map() }, version: 0 });
|
||||
expect(out?.state.m instanceof Map).toBe(true);
|
||||
expect(out?.state.m.size).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips a Map<number,string> and preserves numeric key semantics", async () => {
|
||||
const m = new Map<number, string>([[1, "a"]]);
|
||||
const out = await roundTrip({ state: { m }, version: 0 });
|
||||
const m2 = out!.state.m as Map<number, string>;
|
||||
expect(m2 instanceof Map).toBe(true);
|
||||
expect(m2.get(1)).toBe("a");
|
||||
});
|
||||
|
||||
it("round-trips nested Map inside arrays/objects", async () => {
|
||||
const payload = {
|
||||
state: {
|
||||
list: [new Map<number, number>([[2, 3]])],
|
||||
obj: { inner: new Map<string, number>([["k", 7]]) },
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
const out = await roundTrip(payload);
|
||||
|
||||
expect(out!.state.list[0] instanceof Map).toBe(true);
|
||||
expect((out!.state.list[0] as Map<number, number>).get(2)).toBe(3);
|
||||
expect(out!.state.obj.inner instanceof Map).toBe(true);
|
||||
expect((out!.state.obj.inner as Map<string, number>).get("k")).toBe(7);
|
||||
});
|
||||
|
||||
// Uint8Array
|
||||
it("round-trips a Uint8Array (simple)", async () => {
|
||||
const u8 = new Uint8Array([1, 2, 255]);
|
||||
const out = await roundTrip({ state: { u8 }, version: 0 });
|
||||
const u2 = out!.state.u8 as Uint8Array;
|
||||
expect(u2 instanceof Uint8Array).toBe(true);
|
||||
expect(Array.from(u2)).toEqual([1, 2, 255]);
|
||||
});
|
||||
|
||||
it("round-trips a Uint8Array view with non-zero byteOffset", async () => {
|
||||
const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer;
|
||||
const view = new Uint8Array(buf, 2, 4); // [8,7,6,5]
|
||||
const out = await roundTrip({ state: { view }, version: 0 });
|
||||
const v2 = out!.state.view as Uint8Array;
|
||||
expect(v2 instanceof Uint8Array).toBe(true);
|
||||
expect(Array.from(v2)).toEqual([8, 7, 6, 5]);
|
||||
// ensure it's a standalone buffer now, without offset
|
||||
expect(v2.byteOffset).toBe(0);
|
||||
});
|
||||
|
||||
// Mixed & nested structures
|
||||
it("round-trips Map values containing objects with nested Uint8Array", async () => {
|
||||
const inner = { key: new Uint8Array([7, 8]) };
|
||||
const m = new Map<number, { key: Uint8Array }>([[42, inner]]);
|
||||
const out = await roundTrip({ state: { m }, version: 0 });
|
||||
const m2 = out!.state.m as Map<number, { key: Uint8Array }>;
|
||||
expect(m2 instanceof Map).toBe(true);
|
||||
|
||||
const got = m2.get(42)!;
|
||||
expect(got.key instanceof Uint8Array).toBe(true);
|
||||
expect(Array.from(got.key)).toEqual([7, 8]);
|
||||
});
|
||||
|
||||
it("round-trips deep nesting (array → object → map → u8)", async () => {
|
||||
const payload = {
|
||||
state: {
|
||||
arr: [
|
||||
{
|
||||
obj: {
|
||||
m: new Map<string, Uint8Array>([["k", new Uint8Array([9])]]),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
const out = await roundTrip(payload);
|
||||
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>;
|
||||
expect(m2 instanceof Map).toBe(true);
|
||||
const u = m2.get("k")!;
|
||||
expect(u instanceof Uint8Array).toBe(true);
|
||||
expect(Array.from(u)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("does not alter plain objects/arrays", async () => {
|
||||
const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 };
|
||||
const out = await roundTrip(payload);
|
||||
expect(out).toEqual(payload);
|
||||
});
|
||||
|
||||
it("revives envelope-looking objects", async () => {
|
||||
// Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope.
|
||||
const forged = JSON.stringify({
|
||||
state: { m: { __datatype: "Map", value: [[1, "x"]] } },
|
||||
version: 0,
|
||||
});
|
||||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged);
|
||||
const out = await store.getItem("forged");
|
||||
expect(getSpy).toHaveBeenCalled();
|
||||
const m2 = out!.state.m as Map<number, string>;
|
||||
expect(m2 instanceof Map).toBe(true);
|
||||
expect(m2.get(1)).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { del, get, set } from "idb-keyval";
|
||||
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
|
||||
|
||||
export const zustandIndexDBStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
return (await get(name)) || null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value);
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name);
|
||||
},
|
||||
};
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
type Envelope<Tag extends string = string, V = unknown> = {
|
||||
__datatype: Tag;
|
||||
value: V;
|
||||
};
|
||||
|
||||
interface Handler<T, Tag extends string, Encoded> {
|
||||
tag: Tag;
|
||||
test(val: unknown): val is T;
|
||||
serialize(val: T): Encoded;
|
||||
revive(encoded: Encoded): T;
|
||||
}
|
||||
|
||||
function isObject(x: unknown): x is AnyRecord {
|
||||
return typeof x === "object" && x !== null;
|
||||
}
|
||||
function isEnvelope(x: unknown): x is Envelope {
|
||||
return isObject(x) && typeof x.__datatype === "string" && "value" in x;
|
||||
}
|
||||
|
||||
// Map handler
|
||||
type SerializedMap<K = unknown, V = unknown> = Array<[K, V]>;
|
||||
const mapHandler: Handler<Map<unknown, unknown>, "Map", SerializedMap> = {
|
||||
tag: "Map",
|
||||
test: (val): val is Map<unknown, unknown> => val instanceof Map,
|
||||
serialize: (map) => Array.from(map.entries()),
|
||||
revive: (pairs) => new Map(pairs),
|
||||
};
|
||||
|
||||
// Uint8Array handler
|
||||
const uint8ArrayHandler: Handler<Uint8Array, "Uint8Array", number[]> = {
|
||||
tag: "Uint8Array",
|
||||
test: (val): val is Uint8Array => val instanceof Uint8Array,
|
||||
serialize: (uint8array) => Array.from(uint8array),
|
||||
revive: (arr) => new Uint8Array(arr),
|
||||
};
|
||||
|
||||
const defaultHandlers = [mapHandler, uint8ArrayHandler] as const;
|
||||
|
||||
function makeJson<H extends Handler<unknown, string, unknown>>(handlers: readonly H[]) {
|
||||
const byTag = new Map<H["tag"], H>();
|
||||
for (const handler of handlers) {
|
||||
byTag.set(handler.tag, handler);
|
||||
}
|
||||
|
||||
const replacer = (_: string, value: unknown): unknown => {
|
||||
for (const handler of handlers) {
|
||||
if (handler.test(value)) {
|
||||
const encoded = handler.serialize(value as never);
|
||||
const envelope: Envelope<typeof handler.tag, typeof encoded> = {
|
||||
__datatype: handler.tag,
|
||||
value: encoded,
|
||||
};
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const reviver = (_: string, value: unknown): unknown => {
|
||||
if (isEnvelope(value)) {
|
||||
const handler = byTag.get(value.__datatype);
|
||||
if (handler) {
|
||||
return handler.revive((value as Envelope<H["tag"], unknown>).value as never);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return { replacer, reviver };
|
||||
}
|
||||
|
||||
export function createStorage<T, H extends Handler<unknown, string, unknown> = never>(
|
||||
extraHandlers: readonly H[] = [] as const,
|
||||
): PersistStorage<T> {
|
||||
const { replacer, reviver } = makeJson([...defaultHandlers, ...extraHandlers]);
|
||||
return {
|
||||
getItem: async (name): Promise<StorageValue<T> | null> => {
|
||||
const str = await zustandIndexDBStorage.getItem(name);
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(str, reviver) as StorageValue<T>;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing persisted state (${name}):`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (name, newValue: StorageValue<T>): Promise<void> => {
|
||||
try {
|
||||
const str = JSON.stringify(newValue, replacer);
|
||||
await zustandIndexDBStorage.setItem(name, str);
|
||||
} catch (error) {
|
||||
console.error(`Error stringifying or setting persisted state (${name}):`, error);
|
||||
}
|
||||
},
|
||||
removeItem: async (name): Promise<void> => {
|
||||
await zustandIndexDBStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
|
||||
import { type Device, type MessageStore, MessageType, type NodeDB } from "@core/stores";
|
||||
import { type MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
connection: MeshDevice,
|
||||
messageStore: MessageStore,
|
||||
nodeDB: NodeDB,
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
|
||||
device.addMetadata(metadataPacket.from, metadataPacket.data);
|
||||
});
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
switch (routingPacket.data.variant.case) {
|
||||
case "errorReason": {
|
||||
if (routingPacket.data.variant.value === Protobuf.Mesh.Routing_Error.NONE) {
|
||||
return;
|
||||
}
|
||||
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeReply": {
|
||||
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeRequest": {
|
||||
console.info(`Route Request: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.events.onTelemetryPacket.subscribe((telemetryPacket) => {
|
||||
// Fold live device-metrics telemetry into the node so battery / channel
|
||||
// utilisation / voltage stay current between NodeInfo broadcasts.
|
||||
if (telemetryPacket.data.variant.case === "deviceMetrics") {
|
||||
nodeDB.addDeviceMetrics({
|
||||
...telemetryPacket,
|
||||
data: telemetryPacket.data.variant.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
connection.events.onDeviceStatus.subscribe((status) => {
|
||||
device.setStatus(status);
|
||||
});
|
||||
|
||||
connection.events.onWaypointPacket.subscribe((waypoint) => {
|
||||
const { data, channel, from, rxTime } = waypoint;
|
||||
device.addWaypoint(data, channel, from, rxTime);
|
||||
});
|
||||
|
||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
useNewNodeNum(device.id, nodeInfo);
|
||||
myNodeNum = nodeInfo.myNodeNum;
|
||||
});
|
||||
|
||||
connection.events.onUserPacket.subscribe((user) => {
|
||||
nodeDB.addUser(user);
|
||||
});
|
||||
|
||||
connection.events.onPositionPacket.subscribe((position) => {
|
||||
nodeDB.addPosition(position);
|
||||
});
|
||||
|
||||
// NOTE: Node handling is managed by the nodeDB
|
||||
// Nodes are added via subscriptions.ts and stored in nodeDB
|
||||
// Configuration is handled directly by meshDevice.configure() in useConnections
|
||||
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||
nodeDB.addNode(nodeInfo);
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
device.addChannel(channel);
|
||||
});
|
||||
connection.events.onConfigPacket.subscribe((config) => {
|
||||
device.setConfig(config);
|
||||
});
|
||||
connection.events.onModuleConfigPacket.subscribe((moduleConfig) => {
|
||||
device.setModuleConfig(moduleConfig);
|
||||
});
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
// incoming and outgoing messages are handled by this event listener
|
||||
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||
const message = dto.toMessage();
|
||||
messageStore.saveMessage(message);
|
||||
|
||||
if (message.type === MessageType.Direct) {
|
||||
if (message.to === myNodeNum) {
|
||||
device.incrementUnread(messagePacket.from);
|
||||
}
|
||||
} else if (message.type === MessageType.Broadcast) {
|
||||
if (message.from !== myNodeNum) {
|
||||
device.incrementUnread(message.channel);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||
device.addTraceRoute({
|
||||
...traceRoutePacket,
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onPendingSettingsChange.subscribe((state) => {
|
||||
device.setPendingSettingsChanges(state);
|
||||
});
|
||||
|
||||
connection.events.onMeshPacket.subscribe((meshPacket) => {
|
||||
nodeDB.processPacket({
|
||||
from: meshPacket.from,
|
||||
snr: meshPacket.rxSnr,
|
||||
time: meshPacket.rxTime,
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onClientNotificationPacket.subscribe((clientNotificationPacket) => {
|
||||
device.addClientNotification(clientNotificationPacket);
|
||||
device.setDialogOpen("clientNotification", true);
|
||||
});
|
||||
|
||||
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
|
||||
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
|
||||
});
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
switch (routingPacket.data.variant.value) {
|
||||
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
nodeDB.setNodeError(routingPacket.from, routingPacket?.data?.variant?.value);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
nodeDB.setNodeError(routingPacket.from, routingPacket?.data?.variant?.value);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface EnumLike {
|
||||
[key: number]: string | number;
|
||||
}
|
||||
|
||||
export const bitwiseEncode = (enumValues: number[]): number => {
|
||||
return enumValues.reduce((acc, curr) => acc | curr, 0);
|
||||
};
|
||||
|
||||
export const bitwiseDecode = (value: number, decodeEnum: EnumLike): number[] => {
|
||||
const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean);
|
||||
return enumValues.map((b) => value & b).filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getColorFromNodeNum, hexToRgb, isLightColor, type RGBColor, rgbToHex } from "./color.ts";
|
||||
|
||||
describe("hexToRgb", () => {
|
||||
it.each([
|
||||
[0x000000, { r: 0, g: 0, b: 0 }],
|
||||
[0xffffff, { r: 255, g: 255, b: 255 }],
|
||||
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
|
||||
[0xff8000, { r: 255, g: 128, b: 0 }],
|
||||
])("parses 0x%s correctly", (hex, expected) => {
|
||||
expect(hexToRgb(hex)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
it.each<[RGBColor, number]>([
|
||||
[{ r: 0, g: 0, b: 0 }, 0x000000],
|
||||
[{ r: 255, g: 255, b: 255 }, 0xffffff],
|
||||
[{ r: 0x12, g: 0x34, b: 0x56 }, 0x123456],
|
||||
[{ r: 255, g: 128, b: 0 }, 0xff8000],
|
||||
])("packs %j into 0x%s", (rgb, expected) => {
|
||||
expect(rgbToHex(rgb)).toBe(expected);
|
||||
});
|
||||
|
||||
it("rounds component values before packing", () => {
|
||||
expect(rgbToHex({ r: 12.2, g: 12.8, b: 99.5 })).toBe((12 << 16) | (13 << 8) | 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexToRgb ⟷ rgbToHex round-trip", () => {
|
||||
it("is identity for representative values (masked to 24-bit)", () => {
|
||||
const samples = [0, 1, 0x7fffff, 0x800000, 0xffffff, 0x123456, 0x00ff00];
|
||||
for (const hex of samples) {
|
||||
const rgb = hexToRgb(hex);
|
||||
expect(rgbToHex(rgb)).toBe(hex & 0xffffff);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds for random 24-bit values", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hex = Math.floor(Math.random() * 0x1000000); // 0..0xFFFFFF
|
||||
expect(rgbToHex(hexToRgb(hex))).toBe(hex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLightColor", () => {
|
||||
it("detects obvious extremes", () => {
|
||||
expect(isLightColor({ r: 255, g: 255, b: 255 })).toBe(true); // white
|
||||
expect(isLightColor({ r: 0, g: 0, b: 0 })).toBe(false); // black
|
||||
});
|
||||
|
||||
it("respects the 127.5 threshold at boundary", () => {
|
||||
// mid-gray 127 → false, 128 → true (given the formula and 127.5 threshold)
|
||||
expect(isLightColor({ r: 127, g: 127, b: 127 })).toBe(false);
|
||||
expect(isLightColor({ r: 128, g: 128, b: 128 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getColorFromNodeNum", () => {
|
||||
it.each([
|
||||
[0x000000, { r: 0, g: 0, b: 0 }],
|
||||
[0xffffff, { r: 255, g: 255, b: 255 }],
|
||||
[0x123456, { r: 0x12, g: 0x34, b: 0x56 }],
|
||||
])("extracts RGB from lower 24 bits of %s", (nodeNum, expected) => {
|
||||
expect(getColorFromNodeNum(nodeNum)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("matches hexToRgb when masking to 24 bits", () => {
|
||||
const nodeNums = [1127947528, 42, 999999, 0xfeef12, 0xfeedface, -123456];
|
||||
for (const n of nodeNums) {
|
||||
// JS bitwise ops use signed 32-bit, so mask the lower 24 bits for comparison.
|
||||
const masked = n & 0xffffff;
|
||||
expect(getColorFromNodeNum(n)).toEqual(hexToRgb(masked));
|
||||
}
|
||||
});
|
||||
|
||||
it("always yields components within 0..255", () => {
|
||||
const color = getColorFromNodeNum(Math.floor(Math.random() * 2 ** 31));
|
||||
for (const v of Object.values(color)) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface RGBColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const hexToRgb = (hex: number): RGBColor => ({
|
||||
r: (hex & 0xff0000) >> 16,
|
||||
g: (hex & 0x00ff00) >> 8,
|
||||
b: hex & 0x0000ff,
|
||||
});
|
||||
|
||||
export const rgbToHex = (c: RGBColor): number =>
|
||||
(Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
|
||||
|
||||
export const isLightColor = (c: RGBColor): boolean =>
|
||||
(c.r * 299 + c.g * 587 + c.b * 114) / 1000 > 127.5;
|
||||
|
||||
export const getColorFromNodeNum = (nodeNum: number): RGBColor => {
|
||||
// Extract RGB values directly from nodeNum (treated as hex color)
|
||||
const r = (nodeNum & 0xff0000) >> 16;
|
||||
const g = (nodeNum & 0x00ff00) >> 8;
|
||||
const b = nodeNum & 0x0000ff;
|
||||
|
||||
return { r, g, b };
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { debounce } from "./debounce.ts";
|
||||
|
||||
describe("debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("delays executing the callback until after wait time has elapsed", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("only executes the callback once if called multiple times within wait period", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
debouncedFunction();
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the timer when called again during wait period", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
debouncedFunction();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes arguments to the callback function", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const debouncedFunction = debounce(mockCallback, 500);
|
||||
|
||||
debouncedFunction("test", 123);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(mockCallback).toHaveBeenCalledWith("test", 123);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
type Callback<Args extends unknown[]> = (...args: Args) => void;
|
||||
|
||||
export function debounce<Args extends unknown[]>(
|
||||
callback: Callback<Args>,
|
||||
wait: number,
|
||||
): Callback<Args> {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepCompareConfig } from "./deepCompareConfig.ts";
|
||||
|
||||
describe("deepCompareConfig", () => {
|
||||
it("returns true for identical primitives", () => {
|
||||
expect(deepCompareConfig(5, 5)).toBe(true);
|
||||
expect(deepCompareConfig("foo", "foo")).toBe(true);
|
||||
expect(deepCompareConfig(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different primitives", () => {
|
||||
expect(deepCompareConfig(5, 6)).toBe(false);
|
||||
expect(deepCompareConfig("foo", "bar")).toBe(false);
|
||||
expect(deepCompareConfig(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nulls correctly", () => {
|
||||
expect(deepCompareConfig(null, null)).toBe(true);
|
||||
expect(deepCompareConfig(null, undefined)).toBe(false);
|
||||
expect(deepCompareConfig(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("allows undefined in working when allowUndefined is true", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects undefined in working when allowUndefined is false", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares arrays deeply", () => {
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
|
||||
});
|
||||
|
||||
it("compares objects deeply", () => {
|
||||
const existing = { x: 10, y: { z: 20 } };
|
||||
const workingEqual = { x: 10, y: { z: 20 } };
|
||||
const workingDiff = { x: 10, y: { z: 21 } };
|
||||
|
||||
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
|
||||
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores $typeName key in existing", () => {
|
||||
const existing = { $typeName: "Test", a: 1 };
|
||||
const working = { a: 1 };
|
||||
expect(deepCompareConfig(existing, working)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when working has extra keys", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows working arrays to be shorter if allowUndefined is true", () => {
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares Uint8Array strictly: equal bytes -> true", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 3]);
|
||||
expect(deepCompareConfig(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("compares Uint8Array strictly: different bytes -> false", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 4]);
|
||||
expect(deepCompareConfig(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("Uint8Array vs undefined is false even when allowUndefined is true", () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
expect(deepCompareConfig(a, undefined, true)).toBe(false);
|
||||
expect(deepCompareConfig(undefined, a, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("nested Uint8Array fields must match exactly", () => {
|
||||
const existing = { data: new Uint8Array([9, 8, 7]) };
|
||||
const workingEqual = { data: new Uint8Array([9, 8, 7]) };
|
||||
const workingDiff = { data: new Uint8Array([9, 8, 6]) };
|
||||
const workingUndef = { data: undefined as unknown };
|
||||
|
||||
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
|
||||
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
|
||||
// still false even with allowUndefined
|
||||
expect(deepCompareConfig(existing, workingUndef, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("arrays containing Uint8Array: element must match exactly", () => {
|
||||
const a = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const b = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const c = [new Uint8Array([1, 2]), new Uint8Array([3, 9])];
|
||||
|
||||
expect(deepCompareConfig(a, b)).toBe(true);
|
||||
expect(deepCompareConfig(a, c)).toBe(false);
|
||||
});
|
||||
|
||||
it("shorter working array with missing Uint8Array element -> false even with allowUndefined", () => {
|
||||
const existing = [new Uint8Array([1, 2]), new Uint8Array([3, 4])];
|
||||
const workingShort = [new Uint8Array([1, 2])]; // missing the second byte array
|
||||
expect(deepCompareConfig(existing, workingShort, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isUint8Array(v: unknown): v is Uint8Array {
|
||||
return v instanceof Uint8Array;
|
||||
}
|
||||
|
||||
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.byteLength; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deepCompareConfig(a: unknown, b: unknown, allowUndefined = false): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isUint8Array(a) || isUint8Array(b)) {
|
||||
return isUint8Array(a) && isUint8Array(b) && bytesEqual(a, b);
|
||||
}
|
||||
|
||||
if (allowUndefined && (a === undefined || b === undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a !== typeof b || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length && !allowUndefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const longestLength = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < longestLength; i++) {
|
||||
if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isObject(a) && isObject(b)) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
const allKeys = new Set([...aKeys, ...bKeys]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === "$typeName") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dotPaths } from "./dotPath.ts";
|
||||
|
||||
describe("dotPaths", () => {
|
||||
it("returns flat keys for a simple object", () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
expect(dotPaths(obj)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns dot notation keys for nested objects", () => {
|
||||
const obj = { a: { b: { c: 1 } }, d: 2 };
|
||||
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]);
|
||||
});
|
||||
|
||||
it("handles arrays at the root", () => {
|
||||
const arr = [{ x: 1 }, { y: 2 }];
|
||||
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]);
|
||||
});
|
||||
|
||||
it("handles arrays nested in objects", () => {
|
||||
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 };
|
||||
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]);
|
||||
});
|
||||
|
||||
it("handles objects nested in arrays", () => {
|
||||
const arr = [{ a: { b: 1 } }, { c: 2 }];
|
||||
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]);
|
||||
});
|
||||
|
||||
it("handles primitive values in arrays", () => {
|
||||
const arr = [1, { a: 2 }, 3];
|
||||
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]);
|
||||
});
|
||||
|
||||
it("handles empty objects and arrays", () => {
|
||||
expect(dotPaths({})).toEqual([]);
|
||||
expect(dotPaths([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles mixed nested structures", () => {
|
||||
const obj = {
|
||||
a: [{ b: 1, c: [2, 3] }, { d: { e: 4 } }],
|
||||
f: 5,
|
||||
};
|
||||
expect(dotPaths(obj)).toEqual(["a.0.b", "a.0.c.0", "a.0.c.1", "a.1.d.e", "f"]);
|
||||
});
|
||||
|
||||
it("handles prefix argument", () => {
|
||||
const obj = { a: { b: 1 } };
|
||||
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]);
|
||||
});
|
||||
|
||||
it("skips null and undefined values", () => {
|
||||
const obj = { a: null, b: undefined, c: { d: 1 } };
|
||||
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type DotPath = { [key: string]: unknown } | unknown[];
|
||||
|
||||
export const dotPaths = <T extends DotPath>(obj: T, prefix = ""): string[] => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.flatMap((v, i) =>
|
||||
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${i}.`) : [`${prefix}${i}`],
|
||||
);
|
||||
}
|
||||
return Object.entries(obj).flatMap(([k, v]) =>
|
||||
v && typeof v === "object" ? dotPaths(v as DotPath, `${prefix}${k}.`) : [`${prefix}${k}`],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("EventBus", () => {
|
||||
beforeEach(() => {
|
||||
// Reset event listeners before each test
|
||||
eventBus.offAll();
|
||||
});
|
||||
|
||||
it("should register an event listener and trigger it on emit", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should remove an event listener with off", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return an unsubscribe function from on", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
|
||||
unsubscribe();
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow multiple listeners for the same event", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should only remove the specific listener when off is called", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback1).not.toHaveBeenCalled();
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
|
||||
});
|
||||
|
||||
it("should not fail when calling off on a non-existent listener", () => {
|
||||
const mockCallback = vi.fn();
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export type EventMap = {
|
||||
"dialog:unsafeRoles": {
|
||||
action: "confirm" | "dismiss";
|
||||
};
|
||||
};
|
||||
|
||||
export type EventName = keyof EventMap;
|
||||
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
|
||||
|
||||
class EventBus {
|
||||
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
|
||||
|
||||
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
|
||||
this.listeners[event]?.push(callback);
|
||||
|
||||
return () => {
|
||||
this.off(event, callback);
|
||||
};
|
||||
}
|
||||
|
||||
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
|
||||
if (!this.listeners[event]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackIndex = this.listeners[event]?.indexOf(callback);
|
||||
if (callbackIndex !== undefined && callbackIndex > -1) {
|
||||
this.listeners[event]?.splice(callbackIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public offAll<T extends EventName>(event?: T): void {
|
||||
if (event) {
|
||||
this.listeners[event] = [];
|
||||
} else {
|
||||
this.listeners = {};
|
||||
}
|
||||
}
|
||||
|
||||
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
|
||||
if (!this.listeners[event]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.listeners[event]?.forEach((callback) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
@@ -0,0 +1,80 @@
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
|
||||
export type LngLat = [number, number];
|
||||
export type Mercator = [number, number];
|
||||
export type Bounds = [[number, number], [number, number]];
|
||||
|
||||
const INT_DEG = 1e7;
|
||||
const EARTH_RADIUS = 6378137;
|
||||
|
||||
export const toLngLat = (position?: { latitudeI?: number; longitudeI?: number }): LngLat => [
|
||||
(position?.longitudeI ?? 0) / INT_DEG,
|
||||
(position?.latitudeI ?? 0) / INT_DEG,
|
||||
];
|
||||
|
||||
export const hasPos = (position?: { latitudeI?: number; longitudeI?: number }) =>
|
||||
Number.isFinite(position?.latitudeI) &&
|
||||
Number.isFinite(position?.longitudeI) &&
|
||||
!(position?.latitudeI === 0 && position?.longitudeI === 0);
|
||||
|
||||
export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => {
|
||||
if (coords.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turfCoords = coords.map(([lng, lat]) => [lat, lng]);
|
||||
const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords));
|
||||
|
||||
return [
|
||||
[minLng, minLat],
|
||||
[maxLng, maxLat],
|
||||
];
|
||||
};
|
||||
|
||||
const deg2rad = (d: number) => (d * Math.PI) / 180;
|
||||
const rad2deg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
export function lngLatToMercator([lng, lat]: LngLat): Mercator {
|
||||
return [
|
||||
EARTH_RADIUS * deg2rad(lng),
|
||||
EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)),
|
||||
];
|
||||
}
|
||||
|
||||
export function mercatorToLngLat([x, y]: Mercator): LngLat {
|
||||
return [
|
||||
rad2deg(x / EARTH_RADIUS),
|
||||
rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2),
|
||||
];
|
||||
}
|
||||
|
||||
export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) {
|
||||
const phi1 = deg2rad(lat1),
|
||||
phi2 = deg2rad(lat2);
|
||||
const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5);
|
||||
const y = phi2 - phi1;
|
||||
return EARTH_RADIUS * Math.hypot(x, y);
|
||||
}
|
||||
|
||||
export function precisionBitsToMeters(precisionBits: number): number {
|
||||
const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg
|
||||
|
||||
const stepInt = 2 ** (32 - precisionBits);
|
||||
const stepDegrees = stepInt / INT_DEG;
|
||||
return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ);
|
||||
}
|
||||
|
||||
export function bearingDegrees(from: LngLat, to: LngLat): number {
|
||||
const [lambda1deg, phi1deg] = from;
|
||||
const [lambda2deg, phi2deg] = to;
|
||||
|
||||
const phi1 = deg2rad(phi1deg);
|
||||
const phi2 = deg2rad(phi2deg);
|
||||
const deltaLambda = deg2rad(lambda2deg - lambda1deg);
|
||||
|
||||
const y = Math.sin(deltaLambda) * Math.cos(phi2);
|
||||
const x =
|
||||
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
|
||||
|
||||
return (rad2deg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
interface RepoIdentifier {
|
||||
user: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
interface GithubIssueUrlOptions extends Partial<RepoIdentifier> {
|
||||
repoUrl?: string;
|
||||
body?: string;
|
||||
title?: string;
|
||||
labels?: string[];
|
||||
template?: string;
|
||||
assignee?: string;
|
||||
projects?: string[];
|
||||
logs?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
type ValidatedOptions = {
|
||||
repoUrl: string;
|
||||
} & Omit<GithubIssueUrlOptions, "repoUrl" | "user" | "repo">;
|
||||
|
||||
const VALID_PARAMS = [
|
||||
"body",
|
||||
"title",
|
||||
"labels",
|
||||
"template",
|
||||
"assignee",
|
||||
"projects",
|
||||
"version",
|
||||
"logs",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generates a URL for creating a new GitHub issue
|
||||
* @param options Configuration options for the GitHub issue URL
|
||||
* @returns A formatted URL string for creating a new GitHub issue
|
||||
* @throws {Error} If repository information is missing or invalid
|
||||
* @throws {TypeError} If labels or projects are not arrays when provided
|
||||
*/
|
||||
export default function newGithubIssueUrl(options: GithubIssueUrlOptions = {}): string {
|
||||
const validatedOptions = validateOptions(options);
|
||||
const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
|
||||
|
||||
for (const key of VALID_PARAMS) {
|
||||
const value = validatedOptions[key];
|
||||
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === "labels" || key === "projects") && Array.isArray(value)) {
|
||||
url.searchParams.set(key, value.join(","));
|
||||
continue;
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
|
||||
const repoUrl =
|
||||
options.repoUrl ??
|
||||
(options.user && options.repo
|
||||
? `https://github.com/${options.user}/${options.repo}`
|
||||
: undefined);
|
||||
|
||||
if (!repoUrl) {
|
||||
throw new Error(
|
||||
"You need to specify either the `repoUrl` option or both the `user` and `repo` options",
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of ["labels", "projects"] as const) {
|
||||
const value = options[key];
|
||||
if (value !== undefined && !Array.isArray(value)) {
|
||||
throw new TypeError(`The \`${key}\` option should be an array`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
repoUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertIntToIpAddress, convertIpAddressToInt } from "./ip.ts";
|
||||
|
||||
describe("IP Address Conversion Functions", () => {
|
||||
describe("convertIntToIpAddress", () => {
|
||||
it("converts 0 to 0.0.0.0", () => {
|
||||
expect(convertIntToIpAddress(0)).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("converts 16_777_343 to 127.0.0.1", () => {
|
||||
expect(convertIntToIpAddress(16_777_343)).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts 16_820_416 to 192.168.0.1", () => {
|
||||
expect(convertIntToIpAddress(16_820_416)).toBe("192.168.0.1");
|
||||
});
|
||||
|
||||
it("converts 4_294_967_295 to 255.255.255.255", () => {
|
||||
expect(convertIntToIpAddress(4_294_967_295)).toBe("255.255.255.255");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertIpAddressToInt", () => {
|
||||
it("converts 0.0.0.0 to 0", () => {
|
||||
expect(convertIpAddressToInt("0.0.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("converts 127.0.0.1 to 16_777_343", () => {
|
||||
expect(convertIpAddressToInt("127.0.0.1")).toBe(16_777_343);
|
||||
});
|
||||
|
||||
it("converts 192.168.0.1 to 16_820_416", () => {
|
||||
expect(convertIpAddressToInt("192.168.0.1")).toBe(16_820_416);
|
||||
});
|
||||
|
||||
it("converts 255.255.255.255 to 4_294_967_295", () => {
|
||||
expect(convertIpAddressToInt("255.255.255.255")).toBe(4_294_967_295);
|
||||
});
|
||||
|
||||
it("handles non-standard formats", () => {
|
||||
expect(convertIpAddressToInt("1.2.3.4")).toBe(67_305_985);
|
||||
});
|
||||
|
||||
it("handles invalid IP addresses gracefully", () => {
|
||||
expect(convertIpAddressToInt("300.1.2.3")).not.toBeNull();
|
||||
expect(typeof convertIpAddressToInt("300.1.2.3")).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional conversion", () => {
|
||||
it("can convert back and forth", () => {
|
||||
const testIps = ["0.0.0.0", "127.0.0.1", "192.168.1.1", "10.0.0.1", "255.255.255.255"];
|
||||
|
||||
for (const ip of testIps) {
|
||||
const int = convertIpAddressToInt(ip);
|
||||
expect(int).not.toBeNull();
|
||||
if (int !== null && typeof int === "number") {
|
||||
const convertedBack = convertIntToIpAddress(int);
|
||||
expect(convertedBack).toBe(ip);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function convertIntToIpAddress(int: number): string {
|
||||
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff}`;
|
||||
}
|
||||
|
||||
export function convertIpAddressToInt(ip: string): number | undefined {
|
||||
if (!ip) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
ip
|
||||
.split(".")
|
||||
.reverse()
|
||||
.reduce((ipnum, octet) => {
|
||||
return (ipnum << 8) + Number.parseInt(octet, 10);
|
||||
}, 0) >>> 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randId } from "./randId.ts";
|
||||
|
||||
describe("randId", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a number", () => {
|
||||
const result = randId();
|
||||
expect(typeof result).toBe("number");
|
||||
});
|
||||
|
||||
it("returns an integer", () => {
|
||||
const result = randId();
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses Math.random to generate the number", () => {
|
||||
const mockRandom = vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
const result = randId();
|
||||
|
||||
expect(mockRandom).toHaveBeenCalled();
|
||||
expect(result).toBe(Math.floor(0.5 * 1e9));
|
||||
});
|
||||
|
||||
it("returns a value between 0 and 1e9 (exclusive)", () => {
|
||||
const result = randId();
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(1e9);
|
||||
});
|
||||
|
||||
it("returns different values on subsequent calls", () => {
|
||||
vi.spyOn(Math, "random").mockRestore();
|
||||
|
||||
const results = new Set();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
results.add(randId());
|
||||
}
|
||||
|
||||
expect(results.size).toBeGreaterThan(95);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const randId = () => {
|
||||
return Math.floor(Math.random() * 1e9);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export const SNR_THRESHOLD = {
|
||||
GOOD: -7,
|
||||
FAIR: -15,
|
||||
};
|
||||
|
||||
export const RSSI_THRESHOLD = {
|
||||
GOOD: -115,
|
||||
FAIR: -126,
|
||||
};
|
||||
|
||||
export const LINE_COLOR = {
|
||||
GOOD: "#00ff00",
|
||||
FAIR: "#ffe600",
|
||||
BAD: "#f7931a",
|
||||
};
|
||||
|
||||
export const getSignalColor = (snr: number, rssi?: number): string => {
|
||||
if (snr > SNR_THRESHOLD.GOOD && (rssi == null || rssi > RSSI_THRESHOLD.GOOD)) {
|
||||
return LINE_COLOR.GOOD;
|
||||
}
|
||||
if (snr > SNR_THRESHOLD.FAIR && (rssi == null || rssi > RSSI_THRESHOLD.FAIR)) {
|
||||
return LINE_COLOR.FAIR;
|
||||
}
|
||||
return LINE_COLOR.BAD;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export function intlSort<T extends PropertyKey>(
|
||||
arr: T[],
|
||||
locale: Intl.Locale,
|
||||
order: "asc" | "desc" = "asc",
|
||||
): T[] {
|
||||
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
||||
|
||||
return arr.sort((a, b) => {
|
||||
const stringA = String(a);
|
||||
const stringB = String(b);
|
||||
|
||||
if (order === "asc") {
|
||||
return collator.compare(stringA, stringB);
|
||||
}
|
||||
return collator.compare(stringB, stringA);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
interface PluralForms {
|
||||
one: string;
|
||||
other: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface FormatOptions {
|
||||
locale?: string;
|
||||
pluralRules?: Intl.PluralRulesOptions;
|
||||
numberFormat?: Intl.NumberFormatOptions;
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number, forms: PluralForms, options: FormatOptions = {}) {
|
||||
const {
|
||||
locale = "en-US",
|
||||
pluralRules: pluralOptions = { type: "cardinal" },
|
||||
numberFormat: numberOptions = {},
|
||||
} = options;
|
||||
|
||||
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
|
||||
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
|
||||
|
||||
const pluralCategory = pluralRules.select(value);
|
||||
const word = forms[pluralCategory];
|
||||
|
||||
return `${numberFormat.format(value)} ${word}`;
|
||||
}
|
||||
|
||||
export interface LengthValidationResult {
|
||||
isValid: boolean;
|
||||
currentLength: number | null;
|
||||
}
|
||||
|
||||
export function validateMaxByteLength(
|
||||
value: string | null | undefined,
|
||||
maxByteLength: number,
|
||||
): LengthValidationResult {
|
||||
// Ensure maxByteLength is valid
|
||||
if (typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
|
||||
console.warn("validateMaxByteLength: maxByteLength must be a non-negative integer.");
|
||||
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
|
||||
}
|
||||
|
||||
// Handle null or undefined input values
|
||||
if (value === null || value === undefined) {
|
||||
return { isValid: false, currentLength: null };
|
||||
}
|
||||
|
||||
// Check for TextEncoder availability
|
||||
if (typeof TextEncoder === "undefined") {
|
||||
console.error("validateMaxByteLength: TextEncoder API is not available in this environment.");
|
||||
return { isValid: false, currentLength: null }; // Cannot determine byte length
|
||||
}
|
||||
|
||||
try {
|
||||
// Encode the string to UTF-8 bytes and get the length
|
||||
const encoder = new TextEncoder();
|
||||
const currentLength = encoder.encode(value).length;
|
||||
// Perform the byte length check
|
||||
const isValid = currentLength <= maxByteLength;
|
||||
|
||||
// Return the result object
|
||||
return { isValid, currentLength };
|
||||
} catch (error) {
|
||||
// Handle potential errors during encoding
|
||||
console.error("validateMaxByteLength: Error encoding string:", error);
|
||||
return { isValid: false, currentLength: null }; // Encoding failed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
|
||||
export function getX25519PrivateKey(): Uint8Array {
|
||||
const key = x25519.utils.randomPrivateKey();
|
||||
|
||||
// scalar clamping for curve25519, according to
|
||||
// https://www.rfc-editor.org/rfc/rfc7748#section-5
|
||||
key[0] &= 248;
|
||||
key[31] &= 127;
|
||||
key[31] |= 64;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return x25519.getPublicKey(privateKey);
|
||||
}
|
||||
Reference in New Issue
Block a user