* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
159 lines
4.6 KiB
TypeScript
159 lines
4.6 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
|
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
import {
|
|
clearSidebarCallouts,
|
|
createSidebarCalloutState,
|
|
DismissedCalloutKeysSchema,
|
|
dismissSidebarCallout,
|
|
loadDismissedCalloutKeys,
|
|
selectActiveSidebarCallout,
|
|
serializeDismissedCalloutKeys,
|
|
showSidebarCallout,
|
|
type SidebarCalloutEntry,
|
|
type SidebarCalloutOptions,
|
|
type SidebarCalloutState,
|
|
unregisterSidebarCallout,
|
|
} from "./sidebar-callout-state";
|
|
import { readValidatedJson } from "@/storage/validated-storage";
|
|
|
|
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
|
|
|
|
export interface SidebarCalloutsApi {
|
|
show: (callout: SidebarCalloutOptions) => () => void;
|
|
dismiss: (id: string) => void;
|
|
clear: () => void;
|
|
}
|
|
|
|
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
|
|
|
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
|
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
|
|
|
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
|
void AsyncStorage.setItem(
|
|
DISMISSED_CALLOUTS_STORAGE_KEY,
|
|
serializeDismissedCalloutKeys(keys),
|
|
).catch((error) => {
|
|
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
|
});
|
|
}
|
|
|
|
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
|
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
|
|
const stateRef = useRef<SidebarCalloutState>(state);
|
|
|
|
function commitState(next: SidebarCalloutState): void {
|
|
stateRef.current = next;
|
|
setState(next);
|
|
}
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
async function loadDismissedKeys(): Promise<void> {
|
|
let dismissedKeys: ReadonlySet<string>;
|
|
try {
|
|
const value = await readValidatedJson(
|
|
AsyncStorage,
|
|
DISMISSED_CALLOUTS_STORAGE_KEY,
|
|
DismissedCalloutKeysSchema,
|
|
);
|
|
dismissedKeys = new Set(value ?? []);
|
|
} catch (error) {
|
|
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
|
dismissedKeys = stateRef.current.dismissedKeys;
|
|
}
|
|
|
|
if (mounted) {
|
|
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
|
|
}
|
|
}
|
|
|
|
void loadDismissedKeys();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
|
const result = showSidebarCallout(stateRef.current, callout);
|
|
commitState(result.state);
|
|
|
|
return () => {
|
|
commitState(
|
|
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
|
|
);
|
|
};
|
|
});
|
|
|
|
const dismiss = useStableEvent((id: string) => {
|
|
const result = dismissSidebarCallout(stateRef.current, id);
|
|
commitState(result.state);
|
|
|
|
if (result.dismissalKey) {
|
|
persistDismissedCalloutKeys(result.state.dismissedKeys);
|
|
}
|
|
|
|
result.dismissedCallout?.onDismiss?.();
|
|
});
|
|
|
|
const clear = useStableEvent(() => {
|
|
commitState(clearSidebarCallouts(stateRef.current));
|
|
});
|
|
|
|
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
|
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
|
|
|
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: SidebarCalloutProps = {
|
|
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 <SidebarCallout {...cardProps} />;
|
|
}
|