- Sanitize user-facing error messages (no raw API/DB errors) - Remove dead try/catch from Chat.tsx handleSendMessage - Add onError callback for persistence errors in useChat - Add console.error logging to loadConversation - Guard minimize/toggleMinimize against closed overlay state - Improve error dedup bucketing for non-DOMException errors - Add tests: non-Error throws, updateConversation failure, minimize/toggleMinimize guards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
4.1 KiB
TypeScript
155 lines
4.1 KiB
TypeScript
/**
|
|
* @file useChatOverlay.ts
|
|
* @description Hook for managing the global chat overlay state
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
|
|
|
|
interface ChatOverlayState {
|
|
isOpen: boolean;
|
|
isMinimized: boolean;
|
|
}
|
|
|
|
export interface UseChatOverlayOptions {
|
|
onStorageError?: (message: string) => void;
|
|
}
|
|
|
|
interface UseChatOverlayResult extends ChatOverlayState {
|
|
open: () => void;
|
|
close: () => void;
|
|
minimize: () => void;
|
|
expand: () => void;
|
|
toggle: () => void;
|
|
toggleMinimize: () => void;
|
|
}
|
|
|
|
const STORAGE_KEY = "chatOverlayState";
|
|
|
|
const DEFAULT_STATE: ChatOverlayState = {
|
|
isOpen: false,
|
|
isMinimized: false,
|
|
};
|
|
|
|
/**
|
|
* Get a user-friendly error message for localStorage failures
|
|
*/
|
|
function getStorageErrorMessage(error: unknown): string {
|
|
if (error instanceof DOMException) {
|
|
switch (error.name) {
|
|
case "QuotaExceededError":
|
|
return "Storage is full. Chat state may not persist across page refreshes.";
|
|
case "SecurityError":
|
|
return "Storage is unavailable in this browser mode. Chat state will not persist.";
|
|
case "InvalidStateError":
|
|
return "Storage is disabled. Chat state will not persist across page refreshes.";
|
|
}
|
|
}
|
|
return "Unable to save chat state. It may not persist across page refreshes.";
|
|
}
|
|
|
|
/**
|
|
* Load state from localStorage with runtime type validation
|
|
*/
|
|
function loadState(): ChatOverlayState {
|
|
if (typeof window === "undefined") {
|
|
return DEFAULT_STATE;
|
|
}
|
|
|
|
try {
|
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
return safeJsonParse(stored, isChatOverlayState, DEFAULT_STATE);
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to load chat overlay state from localStorage:", error);
|
|
}
|
|
|
|
return DEFAULT_STATE;
|
|
}
|
|
|
|
/**
|
|
* Save state to localStorage, notifying on error (once per error type)
|
|
*/
|
|
function saveState(
|
|
state: ChatOverlayState,
|
|
onStorageError: ((message: string) => void) | undefined,
|
|
notifiedErrors: Set<string>
|
|
): void {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
} catch (error) {
|
|
const errorName =
|
|
error instanceof DOMException
|
|
? error.name
|
|
: error instanceof Error
|
|
? error.constructor.name
|
|
: "UnknownError";
|
|
console.warn("Failed to save chat overlay state to localStorage:", error);
|
|
|
|
if (onStorageError && !notifiedErrors.has(errorName)) {
|
|
notifiedErrors.add(errorName);
|
|
onStorageError(getStorageErrorMessage(error));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom hook for managing chat overlay state
|
|
* Persists state to localStorage for consistency across page refreshes.
|
|
* Enforces invariant: cannot be minimized when closed.
|
|
*/
|
|
export function useChatOverlay(options: UseChatOverlayOptions = {}): UseChatOverlayResult {
|
|
const { onStorageError } = options;
|
|
const [state, setState] = useState<ChatOverlayState>(loadState);
|
|
const notifiedErrorsRef = useRef<Set<string>>(new Set());
|
|
const onStorageErrorRef = useRef(onStorageError);
|
|
onStorageErrorRef.current = onStorageError;
|
|
|
|
// Persist state changes to localStorage
|
|
useEffect(() => {
|
|
saveState(state, onStorageErrorRef.current, notifiedErrorsRef.current);
|
|
}, [state]);
|
|
|
|
const open = useCallback(() => {
|
|
setState({ isOpen: true, isMinimized: false });
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
setState({ isOpen: false, isMinimized: false });
|
|
}, []);
|
|
|
|
const minimize = useCallback(() => {
|
|
setState((prev) => (prev.isOpen ? { ...prev, isMinimized: true } : prev));
|
|
}, []);
|
|
|
|
const expand = useCallback(() => {
|
|
setState((prev) => ({ ...prev, isMinimized: false }));
|
|
}, []);
|
|
|
|
const toggle = useCallback(() => {
|
|
setState((prev) => {
|
|
const newIsOpen = !prev.isOpen;
|
|
return { isOpen: newIsOpen, isMinimized: newIsOpen ? prev.isMinimized : false };
|
|
});
|
|
}, []);
|
|
|
|
const toggleMinimize = useCallback(() => {
|
|
setState((prev) => (prev.isOpen ? { ...prev, isMinimized: !prev.isMinimized } : prev));
|
|
}, []);
|
|
|
|
return {
|
|
...state,
|
|
open,
|
|
close,
|
|
minimize,
|
|
expand,
|
|
toggle,
|
|
toggleMinimize,
|
|
};
|
|
}
|