Add sidebar callout surface

This commit is contained in:
Mohamed Boudra
2026-04-23 15:19:40 +07:00
parent e21ef2fc9b
commit 576cc0b668
15 changed files with 1812 additions and 316 deletions
@@ -0,0 +1,215 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});
@@ -0,0 +1,232 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type CalloutAction,
CalloutCard,
type CalloutCardProps,
type CalloutVariant,
} from "@/components/callout-card";
import { useStableEvent } from "@/hooks/use-stable-event";
export type SidebarCalloutAction = CalloutAction;
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: CalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void;
dismiss: (id: string) => void;
clear: () => void;
}
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
(error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
},
);
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
useEffect(() => {
let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
.then((value) => {
if (!mounted) {
return;
}
const nextKeys = parseDismissedCalloutKeys(value);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
})
.catch((error) => {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
})
.finally(() => {
if (mounted) {
setDismissalStorageLoaded(true);
}
});
return () => {
mounted = false;
};
}, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => {
tokenRef.current += 1;
const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
return () => {
const updated = calloutsRef.current.filter(
(entry) => entry.id !== callout.id || entry.token !== token,
);
calloutsRef.current = updated;
setCallouts(updated);
};
});
const dismiss = useStableEvent((id: string) => {
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
}
dismissed?.onDismiss?.();
});
const clear = useStableEvent(() => {
calloutsRef.current = [];
setCallouts([]);
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
return (
<SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={activeCallout}>
{children}
</SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider>
);
}
export function useSidebarCallouts(): SidebarCalloutsApi {
const api = useContext(SidebarCalloutApiContext);
if (!api) {
throw new Error("useSidebarCallouts must be used within SidebarCalloutProvider");
}
return api;
}
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
return useContext(SidebarCalloutStateContext);
}
export function SidebarCalloutViewport() {
const activeCallout = useActiveSidebarCallout();
const api = useSidebarCallouts();
if (!activeCallout) {
return null;
}
const cardProps: CalloutCardProps = {
title: activeCallout.title,
description: activeCallout.description,
icon: activeCallout.icon,
variant: activeCallout.variant,
actions: activeCallout.actions,
onDismiss:
activeCallout.dismissible === false ? undefined : () => api.dismiss(activeCallout.id),
testID: activeCallout.testID,
};
return <CalloutCard {...cardProps} />;
}