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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const randId = () => {
|
||||
return Math.floor(Math.random() * 1e9);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
|
||||
export function getX25519PrivateKey(): Uint8Array {
|
||||
const key = x25519.utils.randomPrivateKey();
|
||||
|
||||
// scalar clamping for curve25519, according to
|
||||
// https://www.rfc-editor.org/rfc/rfc7748#section-5
|
||||
key[0] &= 248;
|
||||
key[31] &= 127;
|
||||
key[31] |= 64;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return x25519.getPublicKey(privateKey);
|
||||
}
|
||||
Reference in New Issue
Block a user