Inline base64 source maps had been checked into tracked src files. This strips those comments from the repository without changing runtime behavior or adding ongoing guardrails, per the requested one-time cleanup scope. Constraint: Keep this change limited to tracked source cleanup only Rejected: Add CI/source verification guard | user requested one-time cleanup only Confidence: high Scope-risk: narrow Reversibility: clean Directive: If these directives reappear, fix the producing transform instead of reintroducing repo-side cleanup code Tested: rg -n "sourceMappingURL" ., bun run smoke, bun run verify:privacy, bun run test:provider, npm run test:provider-recommendation Not-tested: bun run typecheck (repository has many pre-existing unrelated failures) Co-authored-by: anandh8x <test@example.com>
253 lines
8.8 KiB
TypeScript
253 lines
8.8 KiB
TypeScript
import type * as React from 'react';
|
|
import { useCallback, useEffect } from 'react';
|
|
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js';
|
|
import { logError } from '../utils/log.js';
|
|
import type { Theme } from '../utils/theme.js';
|
|
type Priority = 'low' | 'medium' | 'high' | 'immediate';
|
|
type BaseNotification = {
|
|
key: string;
|
|
/**
|
|
* Keys of notifications that this notification invalidates.
|
|
* If a notification is invalidated, it will be removed from the queue
|
|
* and, if currently displayed, cleared immediately.
|
|
*/
|
|
invalidates?: string[];
|
|
priority: Priority;
|
|
timeoutMs?: number;
|
|
/**
|
|
* Combine notifications with the same key, like Array.reduce().
|
|
* Called as fold(accumulator, incoming) when a notification with a matching
|
|
* key already exists in the queue or is currently displayed.
|
|
* Returns the merged notification (should carry fold forward for future merges).
|
|
*/
|
|
fold?: (accumulator: Notification, incoming: Notification) => Notification;
|
|
};
|
|
type TextNotification = BaseNotification & {
|
|
text: string;
|
|
color?: keyof Theme;
|
|
};
|
|
type JSXNotification = BaseNotification & {
|
|
jsx: React.ReactNode;
|
|
};
|
|
type AddNotificationFn = (content: Notification) => void;
|
|
type RemoveNotificationFn = (key: string) => void;
|
|
export type Notification = TextNotification | JSXNotification;
|
|
const DEFAULT_TIMEOUT_MS = 8000;
|
|
|
|
// Track current timeout to clear it when immediate notifications arrive
|
|
let currentTimeoutId: NodeJS.Timeout | null = null;
|
|
export function useNotifications(): {
|
|
addNotification: AddNotificationFn;
|
|
removeNotification: RemoveNotificationFn;
|
|
} {
|
|
const store = useAppStateStore();
|
|
const setAppState = useSetAppState();
|
|
|
|
// Process queue when current notification finishes or queue changes
|
|
const processQueue = useCallback(() => {
|
|
try {
|
|
setAppState(prev => {
|
|
const next = getNext(prev.notifications.queue);
|
|
if (prev.notifications.current !== null || !next) {
|
|
return prev;
|
|
}
|
|
currentTimeoutId = setTimeout((setAppState, nextKey, processQueue) => {
|
|
currentTimeoutId = null;
|
|
setAppState(prev => {
|
|
// Compare by key instead of reference to handle re-created notifications
|
|
if (prev.notifications.current?.key !== nextKey) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
queue: prev.notifications.queue,
|
|
current: null
|
|
}
|
|
};
|
|
});
|
|
processQueue();
|
|
}, next.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, next.key, processQueue);
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
queue: prev.notifications.queue.filter(_ => _ !== next),
|
|
current: next
|
|
}
|
|
};
|
|
});
|
|
} catch (error) {
|
|
logError(error);
|
|
}
|
|
}, [setAppState]);
|
|
const addNotification = useCallback<AddNotificationFn>((notif: Notification) => {
|
|
try {
|
|
// Handle immediate priority notifications
|
|
if (notif.priority === 'immediate') {
|
|
// Clear any existing timeout since we're showing a new immediate notification
|
|
if (currentTimeoutId) {
|
|
clearTimeout(currentTimeoutId);
|
|
currentTimeoutId = null;
|
|
}
|
|
|
|
// Set up timeout for the immediate notification
|
|
currentTimeoutId = setTimeout((setAppState, notif, processQueue) => {
|
|
currentTimeoutId = null;
|
|
setAppState(prev => {
|
|
// Compare by key instead of reference to handle re-created notifications
|
|
if (prev.notifications.current?.key !== notif.key) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
queue: prev.notifications.queue.filter(_ => !notif.invalidates?.includes(_.key)),
|
|
current: null
|
|
}
|
|
};
|
|
});
|
|
processQueue();
|
|
}, notif.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, notif, processQueue);
|
|
|
|
// Show the immediate notification right away
|
|
setAppState(prev => ({
|
|
...prev,
|
|
notifications: {
|
|
current: notif,
|
|
queue:
|
|
// Only re-queue the current notification if it's not immediate
|
|
[...(prev.notifications.current ? [prev.notifications.current] : []), ...prev.notifications.queue].filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key))
|
|
}
|
|
}));
|
|
return; // IMPORTANT: Exit addNotification for immediate notifications
|
|
}
|
|
|
|
// Handle non-immediate notifications
|
|
setAppState(prev => {
|
|
// Check if we can fold into an existing notification with the same key
|
|
if (notif.fold) {
|
|
// Fold into current notification if keys match
|
|
if (prev.notifications.current?.key === notif.key) {
|
|
const folded = notif.fold(prev.notifications.current, notif);
|
|
// Reset timeout for the folded notification
|
|
if (currentTimeoutId) {
|
|
clearTimeout(currentTimeoutId);
|
|
currentTimeoutId = null;
|
|
}
|
|
currentTimeoutId = setTimeout((setAppState, foldedKey, processQueue) => {
|
|
currentTimeoutId = null;
|
|
setAppState(p => {
|
|
if (p.notifications.current?.key !== foldedKey) {
|
|
return p;
|
|
}
|
|
return {
|
|
...p,
|
|
notifications: {
|
|
queue: p.notifications.queue,
|
|
current: null
|
|
}
|
|
};
|
|
});
|
|
processQueue();
|
|
}, folded.timeoutMs ?? DEFAULT_TIMEOUT_MS, setAppState, folded.key, processQueue);
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
current: folded,
|
|
queue: prev.notifications.queue
|
|
}
|
|
};
|
|
}
|
|
|
|
// Fold into queued notification if keys match
|
|
const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key);
|
|
if (queueIdx !== -1) {
|
|
const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif);
|
|
const newQueue = [...prev.notifications.queue];
|
|
newQueue[queueIdx] = folded;
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
current: prev.notifications.current,
|
|
queue: newQueue
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// Only add to queue if not already present (prevent duplicates)
|
|
const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key));
|
|
const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key;
|
|
if (!shouldAdd) return prev;
|
|
const invalidatesCurrent = prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key);
|
|
if (invalidatesCurrent && currentTimeoutId) {
|
|
clearTimeout(currentTimeoutId);
|
|
currentTimeoutId = null;
|
|
}
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
current: invalidatesCurrent ? null : prev.notifications.current,
|
|
queue: [...prev.notifications.queue.filter(_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key)), notif]
|
|
}
|
|
};
|
|
});
|
|
|
|
// Process queue after adding the notification
|
|
processQueue();
|
|
} catch (error) {
|
|
logError(error);
|
|
}
|
|
}, [setAppState, processQueue]);
|
|
const removeNotification = useCallback<RemoveNotificationFn>((key: string) => {
|
|
try {
|
|
setAppState(prev => {
|
|
const isCurrent = prev.notifications.current?.key === key;
|
|
const inQueue = prev.notifications.queue.some(n => n.key === key);
|
|
if (!isCurrent && !inQueue) {
|
|
return prev;
|
|
}
|
|
if (isCurrent && currentTimeoutId) {
|
|
clearTimeout(currentTimeoutId);
|
|
currentTimeoutId = null;
|
|
}
|
|
return {
|
|
...prev,
|
|
notifications: {
|
|
current: isCurrent ? null : prev.notifications.current,
|
|
queue: prev.notifications.queue.filter(n => n.key !== key)
|
|
}
|
|
};
|
|
});
|
|
processQueue();
|
|
} catch (error) {
|
|
logError(error);
|
|
}
|
|
}, [setAppState, processQueue]);
|
|
|
|
// Process queue on mount if there are notifications in the initial state.
|
|
// Imperative read (not useAppState) — a subscription in a mount-only
|
|
// effect would be vestigial and make every caller re-render on queue changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref
|
|
useEffect(() => {
|
|
if (store.getState().notifications.queue.length > 0) {
|
|
processQueue();
|
|
}
|
|
}, []);
|
|
return {
|
|
addNotification,
|
|
removeNotification
|
|
};
|
|
}
|
|
const PRIORITIES: Record<Priority, number> = {
|
|
immediate: 0,
|
|
high: 1,
|
|
medium: 2,
|
|
low: 3
|
|
};
|
|
export function getNext(queue: Notification[]): Notification | undefined {
|
|
if (queue.length === 0) return undefined;
|
|
return queue.reduce((min, n) => PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min);
|
|
}
|