diff --git a/src/components/dev/DevPanel.tsx b/src/components/dev/DevPanel.tsx index 5c5fed13..961fe22f 100644 --- a/src/components/dev/DevPanel.tsx +++ b/src/components/dev/DevPanel.tsx @@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { TagiumAppId } from "@/runtime/resolveApp"; import { devToastKinds, spawnDevToast } from "./devToast"; +import { DiscoveryFlagsSection } from "./DiscoveryFlagsSection"; type AudioFault = "rate-limit" | "capacity" | "timeout" | "unreachable" | "malformed"; type TunnelFault = "rate-limit" | "capacity" | "timeout" | "empty-body"; @@ -153,7 +154,7 @@ export function DevPanel({ appId }: { appId: TagiumAppId }) { }; return ( -
+
@@ -171,9 +172,10 @@ export function DevPanel({ appId }: { appId: TagiumAppId }) { dev panel
@@ -337,6 +339,8 @@ export function DevPanel({ appId }: { appId: TagiumAppId }) {
+ {appId === "tagium" && } + {appId === "tagium" && (
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(); + }); });