Add monorepo support to project (#682)

* feat: add monorepo support

* fix: correct relatedProjects structure in vercel.json

* update github actions with monorepo support

* removed legacy dep

* moved jsr config into web package
This commit is contained in:
Dan Ditomaso
2025-06-25 21:56:55 -04:00
committed by GitHub
parent 85ee0747d1
commit 1c13343e8a
463 changed files with 7568 additions and 2196 deletions
@@ -0,0 +1,45 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
class NodeInfoFactory {
private static 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;
const hwModel = Protobuf.Mesh.HardwareModel.UNSET;
return create(Protobuf.Mesh.UserSchema, {
id: userId,
longName: longName,
shortName: shortName,
hwModel: hwModel,
isLicensed: false,
});
}
public static 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;
}
node.user = NodeInfoFactory.createDefaultUser(node.num);
}
return node;
}
}
export default NodeInfoFactory;
@@ -0,0 +1,57 @@
import type { Types } from "@meshtastic/core";
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
import { Message } from "@core/stores/messageStore/types.ts";
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 (!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,33 @@
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;
}
+52
View File
@@ -0,0 +1,52 @@
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,53 @@
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,93 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
import { Protobuf } from "@meshtastic/core";
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();
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
updateFavorite: mockUpdateFavorite,
getNode: mockGetNode,
}),
}));
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,39 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface FavoriteNodeOptions {
nodeNum: number;
isFavorite: boolean;
}
export function useFavoriteNode() {
const { updateFavorite, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast();
const updateFavoriteCB = useCallback(
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
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, getNode],
);
return { updateFavorite: updateFavoriteCB };
}
@@ -0,0 +1,96 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
import { Protobuf } from "@meshtastic/core";
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();
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
updateIgnored: mockUpdateIgnore,
getNode: mockGetNode,
}),
}));
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,40 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface IgnoreNodeOptions {
nodeNum: number;
isIgnored: boolean;
}
export function useIgnoreNode() {
const { updateIgnored, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast();
const updateIgnoredCB = useCallback(
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
updateIgnored(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"),
}),
});
},
[updateIgnored, getNode],
);
return { updateIgnored: updateIgnoredCB };
}
@@ -0,0 +1,120 @@
import { Button } from "@components/UI/Button.tsx";
import { useCallback, useEffect, useRef } from "react";
import { useToast } from "@core/hooks/useToast.ts";
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
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 (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,
]);
}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
FALLBACK_LANGUAGE_CODE,
Lang,
LangCode,
supportedLanguages,
} from "@app/i18n-config.ts";
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 compare = useCallback(
(a: string, b: string) => {
return collator.compare(a, b);
},
[collator],
);
return { compare, set, currentLanguage };
}
export default useLang;
@@ -0,0 +1,52 @@
import { act, renderHook } from "@testing-library/react";
import useLocalStorage from "./useLocalStorage.ts";
import { beforeEach, describe, expect, it } from "vitest";
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,182 @@
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
import { useCallback, useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface WindowEventMap {
"local-storage": CustomEvent;
}
}
type UseLocalStorageOptions<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean;
};
const IS_SERVER = typeof window === "undefined";
/**
* Hook for persisting state to localStorage.
*
* @param {string} key - The key to use for localStorage.
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
* @returns A tuple of [storedValue, setValue, removeValue].
*/
export default function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>, () => void] {
const { initializeWithValue = true } = options;
const serializer = useCallback<(value: T) => string>(
(value) => {
if (options.serializer) {
return options.serializer(value);
}
return JSON.stringify(value);
},
[options],
);
const deserializer = useCallback<(value: string) => T>(
(value) => {
if (options.deserializer) {
return options.deserializer(value);
}
// Support 'undefined' as a value
if (value === "undefined") {
return undefined as unknown as T;
}
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
console.error("Error parsing JSON:", error);
return defaultValue; // Return initialValue if parsing fails
}
return parsed as T;
},
[options, initialValue],
);
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
const initialValueToUse = initialValue instanceof Function
? initialValue()
: initialValue;
// Prevent build error "window is undefined" but keep working
if (IS_SERVER) {
return initialValueToUse;
}
try {
const raw = globalThis.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValueToUse;
}
}, [initialValue, key, deserializer]);
const [storedValue, setStoredValue] = useState(() => {
if (initializeWithValue) {
return readValue();
}
return initialValue instanceof Function ? initialValue() : initialValue;
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`,
);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage
globalThis.localStorage.setItem(key, serializer(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
},
[key, serializer, readValue],
);
const removeValue = useCallback(() => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried removing localStorage key “${key}” even though environment is not a client`,
);
}
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
// Remove the key from local storage
globalThis.localStorage.removeItem(key);
// Save state with default value
setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
useEffect(() => {
setStoredValue(readValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const handleStorageChange = useCallback(
(event: StorageEvent | CustomEvent) => {
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
return;
}
setStoredValue(readValue());
},
[key, readValue],
);
useEffect(() => {
addEventListener("storage", handleStorageChange);
// this is a custom event, triggered in writeValueToLocalStorage
addEventListener("local-storage", handleStorageChange);
return () => {
removeEventListener("storage", handleStorageChange);
removeEventListener("local-storage", handleStorageChange);
};
}, []);
return [storedValue, setValue, removeValue];
}
@@ -0,0 +1,68 @@
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]
);
}, []);
return {
pinnedItems,
togglePinnedItem,
};
}
@@ -0,0 +1,149 @@
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,
};
};
+42
View File
@@ -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 { act, renderHook } from "@testing-library/react";
import { useToast } from "@core/hooks/useToast.ts";
import { Button } from "@components/UI/Button.tsx";
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();
});
});
+196
View File
@@ -0,0 +1,196 @@
import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
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 };
+165
View File
@@ -0,0 +1,165 @@
import { produce } from "immer";
import { create } from "zustand";
export interface RasterSource {
enabled: boolean;
title: string;
tiles: string;
tileSize: number;
}
interface ErrorState {
field: string;
message: string;
}
interface ErrorState {
field: string;
message: string;
}
interface AppState {
selectedDevice: number;
devices: {
id: number;
num: number;
}[];
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void;
removeDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
hasFieldError: (field: string) => boolean;
addError: (field: string, message: string) => void;
removeError: (field: string) => void;
clearErrors: () => void;
setNewErrors: (newErrors: ErrorState[]) => void;
}
export const useAppStore = create<AppState>()((set, get) => ({
selectedDevice: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
errors: [],
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(() => ({
selectedDevice: deviceId,
})),
addDevice: (device) =>
set((state) => ({
devices: [...state.devices, device],
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((device) => device.id !== 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,
})),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
}),
);
},
}));
@@ -0,0 +1,89 @@
import { vi } from "vitest";
import { type Device } from "./deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
/**
* 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,
status: 5 as const,
channels: new Map(),
config: {} as Protobuf.LocalOnly.LocalConfig,
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
workingConfig: [],
workingModuleConfig: [],
hardware: {} as Protobuf.Mesh.MyNodeInfo,
metadata: new Map(),
traceroutes: new Map(),
nodeErrors: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
pendingSettingsChanges: false,
messageDraft: "",
unreadCounts: new Map(),
nodesMap: new Map(),
dialog: {
import: false,
QR: false,
shutdown: false,
reboot: false,
rebootOTA: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
managedMode: false,
},
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
setWorkingConfig: vi.fn(),
setWorkingModuleConfig: vi.fn(),
getWorkingConfig: vi.fn(),
getWorkingModuleConfig: vi.fn(),
removeWorkingConfig: vi.fn(),
removeWorkingModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
addChannel: vi.fn(),
addWaypoint: vi.fn(),
addNodeInfo: vi.fn(),
addUser: vi.fn(),
addPosition: vi.fn(),
addConnection: vi.fn(),
addTraceRoute: vi.fn(),
addMetadata: vi.fn(),
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getDialogOpen: vi.fn().mockReturnValue(false),
processPacket: vi.fn(),
setMessageDraft: vi.fn(),
setNodeError: vi.fn(),
clearNodeError: vi.fn(),
getNodeError: vi.fn().mockReturnValue(undefined),
hasNodeError: vi.fn().mockReturnValue(false),
incrementUnread: vi.fn(),
resetUnread: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getNodesLength: vi.fn().mockReturnValue(0),
getNode: vi.fn().mockReturnValue(undefined),
getMyNode: vi.fn(),
sendAdminMessage: vi.fn(),
updateFavorite: vi.fn(),
updateIgnored: vi.fn(),
};
+829
View File
@@ -0,0 +1,829 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
import { createContext, useContext } from "react";
import { create as createStore } from "zustand";
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
export interface ProcessPacketParams {
from: number;
snr: number;
time: number;
}
export type DialogVariant = keyof Device["dialog"];
type NodeError = {
node: number;
error: string;
};
export type ValidConfigType = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined
>;
export type ValidModuleConfigType = Exclude<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
undefined
>;
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
config: Protobuf.LocalOnly.LocalConfig;
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
workingConfig: Protobuf.Config.Config[];
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
hardware: Protobuf.Mesh.MyNodeInfo;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map<number, number>;
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // dont access directly, use getNodes, or getNode
dialog: {
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
rebootOTA: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
managedMode: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
getWorkingConfig: (
payloadVariant: ValidConfigType,
) =>
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>]
| undefined;
getWorkingModuleConfig: (
payloadVariant: ValidModuleConfigType,
) =>
| Protobuf.LocalOnly.LocalModuleConfig[
Exclude<ValidModuleConfigType, undefined>
]
| undefined;
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
addChannel: (channel: Protobuf.Channel.Channel) => void;
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void;
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: MeshDevice) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
incrementUnread: (nodeNum: number) => void;
resetUnread: (nodeNum: number) => void;
getUnreadCount: (nodeNum: number) => number;
getAllUnreadCount: () => number;
getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
) => Protobuf.Mesh.NodeInfo[];
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
getMyNode: () => Protobuf.Mesh.NodeInfo;
sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
updateIgnored: (nodeNum: number, isIgnored: boolean) => void;
}
export interface DeviceState {
addDevice: (id: number) => Device;
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
}
interface PrivateDeviceState extends DeviceState {
devices: Map<number, Device>;
remoteDevices: Map<number, undefined>;
}
export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
devices: new Map(),
remoteDevices: new Map(),
addDevice: (id: number) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.devices.set(id, {
id,
status: Types.DeviceStatusEnum.DeviceDisconnected,
channels: new Map(),
config: create(Protobuf.LocalOnly.LocalConfigSchema),
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
workingConfig: [],
workingModuleConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
metadata: new Map(),
traceroutes: new Map(),
connection: undefined,
activeNode: 0,
waypoints: [],
dialog: {
import: false,
QR: false,
shutdown: false,
reboot: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
rebootOTA: false,
deleteMessages: false,
managedMode: false,
},
pendingSettingsChanges: false,
messageDraft: "",
nodeErrors: new Map(),
unreadCounts: new Map(),
nodesMap: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.status = status;
}
}),
);
},
setConfig: (config: Protobuf.Config.Config) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "device": {
device.config.device = config.payloadVariant.value;
break;
}
case "position": {
device.config.position = config.payloadVariant.value;
break;
}
case "power": {
device.config.power = config.payloadVariant.value;
break;
}
case "network": {
device.config.network = config.payloadVariant.value;
break;
}
case "display": {
device.config.display = config.payloadVariant.value;
break;
}
case "lora": {
device.config.lora = config.payloadVariant.value;
break;
}
case "bluetooth": {
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
}
}
}),
);
},
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "mqtt": {
device.moduleConfig.mqtt = config.payloadVariant.value;
break;
}
case "serial": {
device.moduleConfig.serial = config.payloadVariant.value;
break;
}
case "externalNotification": {
device.moduleConfig.externalNotification =
config.payloadVariant.value;
break;
}
case "storeForward": {
device.moduleConfig.storeForward =
config.payloadVariant.value;
break;
}
case "rangeTest": {
device.moduleConfig.rangeTest =
config.payloadVariant.value;
break;
}
case "telemetry": {
device.moduleConfig.telemetry =
config.payloadVariant.value;
break;
}
case "cannedMessage": {
device.moduleConfig.cannedMessage =
config.payloadVariant.value;
break;
}
case "audio": {
device.moduleConfig.audio = config.payloadVariant.value;
break;
}
case "neighborInfo": {
device.moduleConfig.neighborInfo =
config.payloadVariant.value;
break;
}
case "ambientLighting": {
device.moduleConfig.ambientLighting =
config.payloadVariant.value;
break;
}
case "detectionSensor": {
device.moduleConfig.detectionSensor =
config.payloadVariant.value;
break;
}
case "paxcounter": {
device.moduleConfig.paxcounter =
config.payloadVariant.value;
break;
}
}
}
}),
);
},
setWorkingConfig: (config: Protobuf.Config.Config) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
if (index !== -1) {
device.workingConfig[index] = config;
} else {
device.workingConfig.push(config);
}
}),
);
},
setWorkingModuleConfig: (
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingModuleConfig.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
} else {
device.workingModuleConfig.push(moduleConfig);
}
}),
);
},
getWorkingConfig: (payloadVariant: ValidConfigType) => {
const device = get().devices.get(id);
if (!device) return;
const workingConfig = device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
);
if (
workingConfig?.payloadVariant.case === "deviceUi" ||
workingConfig?.payloadVariant.case === "sessionkey"
) return;
return workingConfig?.payloadVariant.value;
},
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
const device = get().devices.get(id);
if (!device) return;
return device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value;
},
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingConfig = [];
return;
}
const index = device.workingConfig.findIndex(
(wc: Protobuf.Config.Config) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingConfig.splice(index, 1);
}
}),
);
},
removeWorkingModuleConfig: (
payloadVariant?: ValidModuleConfigType,
) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingModuleConfig = [];
return;
}
const index = device.workingModuleConfig.findIndex(
(wc: Protobuf.ModuleConfig.ModuleConfig) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingModuleConfig.splice(index, 1);
}
}),
);
},
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined {
if (!payloadVariant) return;
const device = get().devices.get(id);
if (!device) return;
return {
...device.config[payloadVariant],
...(device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
const device = get().devices.get(id);
if (!device) return;
return {
...device.moduleConfig[payloadVariant],
...(device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.hardware = hardware;
}
}),
);
},
setPendingSettingsChanges: (state) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.pendingSettingsChanges = state;
}
}),
);
},
addChannel: (channel: Protobuf.Channel.Channel) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.channels.set(channel.index, channel);
}
}),
);
},
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
const index = device.waypoints.findIndex((wp) =>
wp.id === waypoint.id
);
if (index !== -1) {
device.waypoints[index] = waypoint;
} else {
device.waypoints.push(waypoint);
}
}
}),
);
},
addNodeInfo: (nodeInfo) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
device.nodesMap.set(nodeInfo.num, nodeInfo);
}),
);
},
setActiveNode: (node) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.activeNode = node;
}
}),
);
},
addUser: (user) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const currentNode = device.nodesMap.get(user.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data;
currentNode.num = user.from;
device.nodesMap.set(user.from, currentNode);
}),
);
},
addPosition: (position) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const currentNode = device.nodesMap.get(position.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data;
currentNode.num = position.from;
device.nodesMap.set(position.from, currentNode);
}),
);
},
addConnection: (connection) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.connection = connection;
}
}),
);
},
addMetadata: (from, metadata) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.metadata.set(from, metadata);
}
}),
);
},
addTraceRoute: (traceroute) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const routes = device.traceroutes.get(traceroute.from) ?? [];
routes.push(traceroute);
device.traceroutes.set(traceroute.from, routes);
}),
);
},
removeNode: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
device.nodesMap.delete(nodeNum);
}),
);
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.dialog[dialog] = open;
}
}),
);
},
getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
return device.dialog[dialog];
},
processPacket(data: ProcessPacketParams) {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const node = device.nodesMap.get(data.from);
if (node) {
node.lastHeard = data.time;
node.snr = data.snr;
device.nodesMap.set(data.from, node);
} else {
device.nodesMap.set(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
lastHeard: data.time,
snr: data.snr,
}),
);
}
}),
);
},
setMessageDraft: (message: string) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.messageDraft = message;
}
}),
);
},
setNodeError: (nodeNum: number, error: string) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
clearNodeError: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
}
}),
);
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.has(nodeNum);
},
incrementUnread: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1);
}),
);
},
getUnreadCount: (nodeNum: number): number => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
return device.unreadCounts.get(nodeNum) ?? 0;
},
getAllUnreadCount: (): number => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
let totalUnread = 0;
device.unreadCounts.forEach((count) => {
totalUnread += count;
});
return totalUnread;
},
resetUnread: (nodeNum: number) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
device.unreadCounts.set(nodeNum, 0);
if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum);
}
}),
);
},
getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
): Protobuf.Mesh.NodeInfo[] => {
const device = get().devices.get(id);
if (!device) {
return [];
}
const allNodes = Array.from(device.nodesMap.values()).filter(
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum,
);
if (filter) {
return allNodes.filter(filter);
}
return allNodes;
},
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
const device = get().devices.get(id);
if (!device) {
return;
}
if (!device.nodesMap.has(nodeNum)) {
return undefined;
}
return device.nodesMap.get(nodeNum);
},
getMyNode: (): Protobuf.Mesh.NodeInfo => {
const device = get().devices.get(id);
if (!device) {
throw new Error(`Device ${id} not found`);
}
return device.nodesMap.get(device.hardware.myNodeNum) ??
create(Protobuf.Mesh.NodeInfoSchema);
},
getNodesLength: () => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
return device.nodesMap.size;
},
sendAdminMessage(message: Protobuf.Admin.AdminMessage) {
const device = get().devices.get(id);
if (!device) return;
device.connection?.sendPacket(
toBinary(Protobuf.Admin.AdminMessageSchema, message),
Protobuf.Portnums.PortNum.ADMIN_APP,
"self",
);
},
updateFavorite(nodeNum: number, isFavorite: boolean) {
const device = get().devices.get(id);
if (!device) return;
const node = device?.nodesMap.get(nodeNum);
if (!node) return;
device.sendAdminMessage(create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
value: nodeNum,
},
}));
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum);
if (node) {
node.isFavorite = isFavorite;
}
}),
);
},
updateIgnored(nodeNum: number, isIgnored: boolean) {
const device = get().devices.get(id);
if (!device) return;
const node = device?.nodesMap.get(nodeNum);
if (!node) return;
device.sendAdminMessage(create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
value: nodeNum,
},
}));
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum);
if (node) {
node.isIgnored = isIgnored;
}
}),
);
},
});
}),
);
const device = get().devices.get(id);
if (!device) {
throw new Error(`Failed to create or retrieve device with ID ${id}`);
}
return device;
},
removeDevice: (id) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.devices.delete(id);
}),
);
},
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
}));
export const DeviceContext = createContext<Device | undefined>(undefined);
export const useDevice = (): Device => {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error("useDevice must be used within a DeviceProvider");
}
return context;
};
@@ -0,0 +1,237 @@
import { create } from "zustand";
// import { persist } from "zustand/middleware";
import { produce } from "immer";
import { Types } from "@meshtastic/core";
// import { storageWithMapSupport } from "../storage/indexDB.ts";
import {
ChannelId,
ClearMessageParams,
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
} from "@core/stores/messageStore/types.ts";
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 MessageStore {
messages: {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
}
export interface MessageStore {
messages: MessageStore["messages"];
draft: Map<Types.Destination, string>;
nodeNum: number; // This device's node number
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
chatType: MessageType;
setNodeNum: (nodeNum: number) => void;
getMyNodeNum: () => number;
saveMessage: (message: Message) => void;
setMessageState: (params: SetMessageStateParams) => void;
getMessages: (params: GetMessagesParams) => Message[];
getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: ClearMessageParams) => void;
clearDraft: (key: Types.Destination) => void;
}
// const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()(
// persist(
(set, get) => ({
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
state.nodeNum = nodeNum;
}));
},
getMyNodeNum: () => get().nodeNum,
saveMessage: (message: Message) => {
set(
produce((state: MessageStore) => {
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>(),
);
}
state.messages.direct.get(conversationId)!.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>(),
);
}
state.messages.broadcast.get(channelId)!.set(
message.messageId,
message,
);
}
}),
);
},
setMessageState: (params: SetMessageStateParams) => {
set(
produce((state: MessageStore) => {
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();
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;
},
clearMessageByMessageId: (params: ClearMessageParams) => {
set(
produce((state: MessageStore) => {
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 {
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.`,
);
}
}),
);
},
getDraft: (key) => {
return get().draft.get(key) ?? "";
},
setDraft: (key, message) => {
set(produce((state: MessageStore) => {
state.draft.set(key, message);
}));
},
clearDraft: (key) => {
set(produce((state: MessageStore) => {
state.draft.delete(key);
}));
},
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}));
},
}),
// {
// name: 'meshtastic-message-store',
// storage: storageWithMapSupport,
// version: CURRENT_STORE_VERSION,
// partialize: (state) => ({
// messages: state.messages,
// nodeNum: state.nodeNum,
// }),
// })
);
@@ -0,0 +1,571 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getConversationId,
MessageState,
MessageType,
useMessageStore,
} from "./index.ts";
import type {
ChannelId,
ConversationId,
Message,
MessageLogMap,
} from "./types.ts";
import { Types } from "@meshtastic/core";
vi.mock("../storage/indexDB.ts", () => {
const memoryStorage: Record<string, string> = {};
return {
storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => {
return await memoryStorage[name] ?? null;
}),
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
memoryStorage[name] = await value;
}),
removeItem: vi.fn(async (name: string): Promise<void> => {
await delete memoryStorage[name];
}),
},
};
});
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("useMessageStore", () => {
const initialState = useMessageStore.getState();
beforeEach(() => {
useMessageStore.setState({
...initialState,
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<Types.Destination, string>(),
}, true);
});
it("should have correct initial state", () => {
const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
expect(state.draft).toBeInstanceOf(Map);
expect(state.draft.size).toBe(0);
expect(state.nodeNum).toBe(0);
expect(state.activeChat).toBe(0);
expect(state.chatType).toBe(MessageType.Broadcast);
});
it("should set nodeNum", () => {
useMessageStore.getState().setNodeNum(myNodeNum);
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
});
describe("saveMessage", () => {
it("should save a direct message with correct Map structure", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
const state = useMessageStore.getState();
const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
// Check if the conversation Map exists
expect(state.messages.direct.has(conversationId)).toBe(true);
const conversationLog = state.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", () => {
useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState();
const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.has(channelId)).toBe(true);
const channelLog = state.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", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState();
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(
state.messages.direct.get(convId1)?.get(
directMessageToOther1.messageId,
),
).toEqual(directMessageToOther1);
expect(
state.messages.direct.get(convId1)?.get(
directMessageFromOther1.messageId,
),
).toEqual(directMessageFromOther1);
const channelId = broadcastMessage1.channel;
expect(
state.messages.broadcast.get(channelId)?.get(
broadcastMessage1.messageId,
),
).toEqual(broadcastMessage1);
});
});
describe("getMessages", () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(directMessageToOther2);
useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage(broadcastMessage2);
});
it("should return broadcast messages for a channel, sorted by date", () => {
const messages = useMessageStore.getState().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 = useMessageStore.getState().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 = useMessageStore.getState().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 = useMessageStore.getState().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 = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 999,
});
expect(messages).toEqual([]);
});
});
describe("setMessageState", () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
});
it("should update state for a direct message", () => {
useMessageStore.getState().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().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", () => {
useMessageStore.getState().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().messages.direct.get(
conversationId,
)?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed);
});
it("should update state for a broadcast message", () => {
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: broadcastMessage1.messageId,
newState: MessageState.Ack,
});
const message = useMessageStore.getState().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(() => {});
useMessageStore.getState().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(() => {});
useMessageStore.getState().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(() => {});
useMessageStore.getState().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", () => {
const extraDirectMessageId = 1011;
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().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);
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
true,
);
expect(state.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);
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
const conversationLog = state.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;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: channelId,
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
expect(state.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;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: extraDirectMessageId,
});
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(
false,
);
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: broadcastChanId,
messageId: broadcastMessage1.messageId,
});
expect(useMessageStore.getState().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(() => {
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 9999,
});
}).not.toThrow();
const state = useMessageStore.getState();
const conversationLog = state.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(() => {
useMessageStore.getState().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", () => {
const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel;
const draftMessage = "This is a draft";
it("should set and get a draft for direct chat", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(
draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(
draftMessage,
);
});
it("should set and get a draft for broadcast chat", () => {
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(
draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(
draftMessage,
);
});
it("should return empty string for non-existent draft", () => {
expect(useMessageStore.getState().getDraft(999)).toBe("");
});
it("should clear a draft", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
useMessageStore.getState().clearDraft(draftKeyDirect);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe("");
});
});
describe("deleteAllMessages", () => {
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(
0,
);
expect(useMessageStore.getState().messages.broadcast.size)
.toBeGreaterThan(0);
useMessageStore.getState().deleteAllMessages();
const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
});
});
});
@@ -0,0 +1,70 @@
import { Types } from "@meshtastic/core";
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
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,41 @@
import React, { 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,91 @@
import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
import { del, get, set } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
type PersistedMessageState = {
messages: {
direct: Map<string, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
nodeNum: number;
};
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 SerializedMap<K = unknown, V = unknown> = {
__dataType: "Map";
value: Array<[K, V]>;
};
// deno-lint-ignore no-explicit-any
type JsonReplacer = (this: any, key: string, value: unknown) => unknown;
const replacer: JsonReplacer = (_, value) => {
if (value instanceof Map) {
const map = value as Map<unknown, unknown>;
const serialized: SerializedMap = {
__dataType: "Map",
value: Array.from(map.entries()),
};
return serialized;
}
return value;
};
// deno-lint-ignore no-explicit-any
type JsonReviver = (this: any, key: string, value: unknown) => unknown;
function isSerializedMap(value: unknown): value is SerializedMap {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const potentialMap = value as Partial<SerializedMap>;
return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
}
const reviver: JsonReviver = (_, value) => {
if (isSerializedMap(value)) {
return new Map(value.value);
}
return value;
};
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (
name,
): Promise<StorageValue<PersistedMessageState> | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) return null;
try {
const parsed = JSON.parse(str, reviver) as StorageValue<
PersistedMessageState
>;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
}
},
setItem: async (
name,
newValue: StorageValue<PersistedMessageState>,
): 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);
},
};
+145
View File
@@ -0,0 +1,145 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core";
import { type MessageStore, MessageType } from "./stores/messageStore/index.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = (
device: Device,
connection: MeshDevice,
messageStore: MessageStore,
) => {
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(() => {
// device.setMetrics(telemetryPacket);
});
connection.events.onDeviceStatus.subscribe((status) => {
device.setStatus(status);
});
connection.events.onWaypointPacket.subscribe((waypoint) => {
const { data } = waypoint;
device.addWaypoint(data);
});
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
device.setHardware(nodeInfo);
messageStore.setNodeNum(nodeInfo.myNodeNum);
myNodeNum = nodeInfo.myNodeNum;
});
connection.events.onUserPacket.subscribe((user) => {
device.addUser(user);
});
connection.events.onPositionPacket.subscribe((position) => {
device.addPosition(position);
});
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
device.addNodeInfo(nodeWithUser);
});
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) => {
device.processPacket({
from: meshPacket.from,
snr: meshPacket.rxSnr,
time: meshPacket.rxTime,
});
});
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}`);
device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[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}`);
device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
}
});
};
+15
View File
@@ -0,0 +1,15 @@
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);
};
+6
View File
@@ -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,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);
});
});
+13
View File
@@ -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,60 @@
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);
});
});
@@ -0,0 +1,58 @@
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function deepCompareConfig(
a: unknown,
b: unknown,
allowUndefined = false,
): boolean {
if (a === b) {
return true;
}
// If allowUndefined is true, and one is undefined, they are considered equal. // This check is placed early to simplify subsequent logic.
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,66 @@
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"]);
});
});
+19
View File
@@ -0,0 +1,19 @@
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 { beforeEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "@core/utils/eventBus.ts";
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
});
});
+54
View File
@@ -0,0 +1,54 @@
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();
+87
View File
@@ -0,0 +1,87 @@
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,
};
}
+70
View File
@@ -0,0 +1,70 @@
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);
}
}
});
});
});
+19
View File
@@ -0,0 +1,19 @@
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);
}, 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);
});
});
+3
View File
@@ -0,0 +1,3 @@
export const randId = () => {
return Math.floor(Math.random() * 1e9);
};
+18
View File
@@ -0,0 +1,18 @@
export function intlSort<T extends PropertyKey>(
arr: T[],
order: "asc" | "desc" = "asc",
locale: Intl.Locale,
): 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);
} else {
return collator.compare(stringB, stringA);
}
});
}
+80
View File
@@ -0,0 +1,80 @@
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
}
}
+17
View File
@@ -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);
}