diff --git a/src/components/dev/DiscoveryFlagsSection.tsx b/src/components/dev/DiscoveryFlagsSection.tsx
new file mode 100644
index 00000000..cadc0b88
--- /dev/null
+++ b/src/components/dev/DiscoveryFlagsSection.tsx
@@ -0,0 +1,50 @@
+import { SparklesIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import {
+ DISCOVERABLE_FEATURES,
+ type DiscoverableFeature,
+ resetFeatureDiscovery,
+ useFeatureDiscovery,
+} from "@/features/discovery/featureDiscovery";
+
+function DiscoveryFlagRow({ feature }: { feature: DiscoverableFeature }) {
+ const discovery = useFeatureDiscovery(feature);
+
+ return (
+
+ );
+}
+
+export function DiscoveryFlagsSection() {
+ return (
+
+
+
+
+ feature discovery
+
+
+
+
+ {DISCOVERABLE_FEATURES.map((feature) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ui/spotlight.tsx b/src/components/ui/spotlight.tsx
index 581b73a5..c8d2f319 100644
--- a/src/components/ui/spotlight.tsx
+++ b/src/components/ui/spotlight.tsx
@@ -65,7 +65,11 @@ function SpotlightContent({
hideWhenDetached
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
- if (event.target instanceof Node && anchorRef.current?.contains(event.target)) {
+ const target = event.target;
+ if (
+ (target instanceof Node && anchorRef.current?.contains(target)) ||
+ (target instanceof Element && target.closest("[data-spotlight-ignore]"))
+ ) {
event.preventDefault();
return;
}
diff --git a/src/features/discovery/featureDiscovery.ts b/src/features/discovery/featureDiscovery.ts
index 09fe3f54..f31b50d2 100644
--- a/src/features/discovery/featureDiscovery.ts
+++ b/src/features/discovery/featureDiscovery.ts
@@ -1,12 +1,20 @@
-import { useState } from "react";
+import { useSyncExternalStore } from "react";
import { Schema } from "effect";
-export type DiscoverableFeature = "share-links";
+export const DISCOVERABLE_FEATURES = ["share-links"] as const;
+
+export type DiscoverableFeature = (typeof DISCOVERABLE_FEATURES)[number];
export const FEATURE_DISCOVERY_STORAGE_KEY = "tagium:feature-discovery";
const decodeSeenFlags = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Boolean));
+const listeners = new Set<() => void>();
+
+const notify = () => {
+ for (const listener of listeners) listener();
+};
+
const loadSeenFlags = (storage?: Pick
) => {
try {
const stored = (storage ?? localStorage).getItem(FEATURE_DISCOVERY_STORAGE_KEY);
@@ -19,31 +27,58 @@ const loadSeenFlags = (storage?: Pick) => {
export const hasSeenFeature = (feature: DiscoverableFeature, storage?: Pick) =>
loadSeenFlags(storage)[feature] === true;
-export const markFeatureSeen = (
+export const setFeatureSeen = (
feature: DiscoverableFeature,
+ seen: boolean,
storage?: Pick,
) => {
try {
+ const { [feature]: _previous, ...others } = loadSeenFlags(storage);
(storage ?? localStorage).setItem(
FEATURE_DISCOVERY_STORAGE_KEY,
- JSON.stringify({ ...loadSeenFlags(storage), [feature]: true }),
+ JSON.stringify(seen ? { ...others, [feature]: true } : others),
);
} catch {
return;
}
+ notify();
+};
+
+export const markFeatureSeen = (
+ feature: DiscoverableFeature,
+ storage?: Pick,
+) => setFeatureSeen(feature, true, storage);
+
+export const resetFeatureDiscovery = (storage?: Pick) => {
+ try {
+ (storage ?? localStorage).removeItem(FEATURE_DISCOVERY_STORAGE_KEY);
+ } catch {
+ return;
+ }
+ notify();
+};
+
+const subscribe = (listener: () => void) => {
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === null || event.key === FEATURE_DISCOVERY_STORAGE_KEY) listener();
+ };
+ listeners.add(listener);
+ globalThis.addEventListener?.("storage", onStorage);
+ return () => {
+ listeners.delete(listener);
+ globalThis.removeEventListener?.("storage", onStorage);
+ };
};
export const useFeatureDiscovery = (
feature: DiscoverableFeature,
storage?: Pick,
) => {
- const [seen, setSeen] = useState(() => hasSeenFeature(feature, storage));
+ const seen = useSyncExternalStore(subscribe, () => hasSeenFeature(feature, storage));
return {
seen,
- markSeen: () => {
- markFeatureSeen(feature, storage);
- setSeen(true);
- },
+ markSeen: () => markFeatureSeen(feature, storage),
+ setSeen: (nextSeen: boolean) => setFeatureSeen(feature, nextSeen, storage),
};
};
diff --git a/tests/unit/features/discovery/featureDiscovery.test.ts b/tests/unit/features/discovery/featureDiscovery.test.ts
index b4a5d87e..1af5cc21 100644
--- a/tests/unit/features/discovery/featureDiscovery.test.ts
+++ b/tests/unit/features/discovery/featureDiscovery.test.ts
@@ -1,8 +1,11 @@
+import { act } from "react-test-renderer";
import { describe, expect, it } from "vite-plus/test";
import {
FEATURE_DISCOVERY_STORAGE_KEY,
hasSeenFeature,
markFeatureSeen,
+ resetFeatureDiscovery,
+ setFeatureSeen,
useFeatureDiscovery,
} from "@/features/discovery/featureDiscovery";
import { renderHook } from "../../support/hookTestHarness";
@@ -13,6 +16,7 @@ const memoryStorage = (initial: Record = {}) => {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
+ removeItem: (key: string) => void values.delete(key),
};
};
@@ -52,4 +56,41 @@ describe("feature discovery", () => {
const nextSession = renderHook(() => useFeatureDiscovery("share-links", storage), undefined);
expect(nextSession.result.seen).toBe(true);
});
+
+ it("unsets one flag while keeping the others", () => {
+ const storage = memoryStorage({
+ [FEATURE_DISCOVERY_STORAGE_KEY]: JSON.stringify({
+ "retired-feature": true,
+ "share-links": true,
+ }),
+ });
+
+ setFeatureSeen("share-links", false, storage);
+
+ expect(hasSeenFeature("share-links", storage)).toBe(false);
+ expect(JSON.parse(storage.values.get(FEATURE_DISCOVERY_STORAGE_KEY)!)).toEqual({
+ "retired-feature": true,
+ });
+ });
+
+ it("pushes flag edits and resets to every mounted hook", () => {
+ const storage = memoryStorage();
+ const hint = renderHook(() => useFeatureDiscovery("share-links", storage), undefined);
+ const devPanel = renderHook(() => useFeatureDiscovery("share-links", storage), undefined);
+
+ act(() => hint.result.markSeen());
+ expect(devPanel.result.seen).toBe(true);
+
+ act(() => devPanel.result.setSeen(false));
+ expect(hint.result.seen).toBe(false);
+
+ act(() => hint.result.markSeen());
+ act(() => resetFeatureDiscovery(storage));
+ expect(storage.values.has(FEATURE_DISCOVERY_STORAGE_KEY)).toBe(false);
+ expect(hint.result.seen).toBe(false);
+ expect(devPanel.result.seen).toBe(false);
+
+ hint.unmount();
+ devPanel.unmount();
+ });
});