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,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 };
|
||||
Reference in New Issue
Block a user