431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Text } from '@/components/ui/text';
|
|
|
|
interface WorkingPlaceholderProps {
|
|
statusText: string | null;
|
|
isWaitingForPermission?: boolean;
|
|
wasAborted?: boolean;
|
|
completionId?: string | null;
|
|
isComplete?: boolean;
|
|
}
|
|
|
|
const MIN_DISPLAY_TIME = 2000;
|
|
const DONE_DISPLAY_TIME = 1500;
|
|
|
|
type ResultState = 'success' | 'aborted' | null;
|
|
|
|
export function WorkingPlaceholder({
|
|
statusText,
|
|
isWaitingForPermission,
|
|
wasAborted,
|
|
completionId,
|
|
isComplete,
|
|
}: WorkingPlaceholderProps) {
|
|
const [displayedStatus, setDisplayedStatus] = useState<string | null>(null);
|
|
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
|
|
const [isVisible, setIsVisible] = useState<boolean>(false);
|
|
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
|
|
const [resultState, setResultState] = useState<ResultState>(null);
|
|
|
|
|
|
const displayStartTimeRef = useRef<number>(0);
|
|
const statusQueueRef = useRef<Array<{ status: string; permission: boolean }>>([]);
|
|
const removalPendingRef = useRef<boolean>(false);
|
|
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const rafIdRef = useRef<number | null>(null);
|
|
const lastCheckTimeRef = useRef<number>(0);
|
|
const lastActiveStatusRef = useRef<string | null>(null);
|
|
const hasShownActivityRef = useRef<boolean>(false);
|
|
const wasAbortedRef = useRef<boolean>(false);
|
|
const isCompleteRef = useRef<boolean>(false);
|
|
const windowFocusRef = useRef<boolean>(true);
|
|
const lastCompletionShownRef = useRef<string | null>(null);
|
|
const resultShownAtRef = useRef<number | null>(null);
|
|
|
|
const activateStatus = (status: string, permission: boolean) => {
|
|
if (fadeTimeoutRef.current) {
|
|
clearTimeout(fadeTimeoutRef.current);
|
|
fadeTimeoutRef.current = null;
|
|
}
|
|
if (resultTimeoutRef.current) {
|
|
clearTimeout(resultTimeoutRef.current);
|
|
resultTimeoutRef.current = null;
|
|
}
|
|
if (status === 'aborted') {
|
|
setDisplayedStatus(null);
|
|
setDisplayedPermission(false);
|
|
setIsFadingOut(false);
|
|
setResultState('aborted');
|
|
lastActiveStatusRef.current = 'aborted';
|
|
hasShownActivityRef.current = true;
|
|
wasAbortedRef.current = true;
|
|
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
requestAnimationFrame(() => setIsVisible(true));
|
|
} else {
|
|
setIsVisible(true);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
setResultState(null);
|
|
setIsFadingOut(false);
|
|
lastActiveStatusRef.current = status;
|
|
hasShownActivityRef.current = true;
|
|
|
|
setDisplayedStatus(status);
|
|
setDisplayedPermission(permission);
|
|
|
|
if (!isVisible) {
|
|
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
requestAnimationFrame(() => {
|
|
setIsVisible(true);
|
|
});
|
|
} else {
|
|
setIsVisible(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const now = Date.now();
|
|
|
|
if (statusText) {
|
|
removalPendingRef.current = false;
|
|
|
|
if (!displayedStatus) {
|
|
activateStatus(statusText, !!isWaitingForPermission);
|
|
displayStartTimeRef.current = now;
|
|
statusQueueRef.current = [];
|
|
} else if (
|
|
statusText !== displayedStatus ||
|
|
!!isWaitingForPermission !== displayedPermission
|
|
) {
|
|
statusQueueRef.current.push({
|
|
status: statusText,
|
|
permission: !!isWaitingForPermission,
|
|
});
|
|
}
|
|
} else {
|
|
removalPendingRef.current = true;
|
|
}
|
|
|
|
}, [statusText, isWaitingForPermission, displayedStatus, displayedPermission, wasAborted]);
|
|
|
|
useEffect(() => {
|
|
if (wasAborted) {
|
|
wasAbortedRef.current = true;
|
|
}
|
|
}, [wasAborted]);
|
|
|
|
useEffect(() => {
|
|
isCompleteRef.current = !!isComplete;
|
|
}, [isComplete]);
|
|
|
|
useEffect(() => {
|
|
if (isComplete) {
|
|
removalPendingRef.current = true;
|
|
}
|
|
}, [isComplete]);
|
|
|
|
useEffect(() => {
|
|
const startFadeOut = (result: ResultState) => {
|
|
if (isFadingOut) {
|
|
return;
|
|
}
|
|
|
|
const hadActiveStatus =
|
|
lastActiveStatusRef.current !== null || hasShownActivityRef.current;
|
|
|
|
if (result && hadActiveStatus) {
|
|
|
|
setIsFadingOut(false);
|
|
setIsVisible(true);
|
|
|
|
setDisplayedStatus(null);
|
|
setDisplayedPermission(false);
|
|
setResultState(result);
|
|
lastActiveStatusRef.current = null;
|
|
|
|
if (result === 'success' && completionId) {
|
|
lastCompletionShownRef.current = completionId;
|
|
}
|
|
|
|
resultShownAtRef.current = Date.now();
|
|
|
|
if (resultTimeoutRef.current) {
|
|
clearTimeout(resultTimeoutRef.current);
|
|
}
|
|
|
|
resultTimeoutRef.current = setTimeout(() => {
|
|
setIsVisible(false);
|
|
setResultState(null);
|
|
hasShownActivityRef.current = false;
|
|
resultTimeoutRef.current = null;
|
|
}, DONE_DISPLAY_TIME);
|
|
} else {
|
|
|
|
setIsFadingOut(true);
|
|
setIsVisible(false);
|
|
setResultState(null);
|
|
|
|
if (fadeTimeoutRef.current) {
|
|
clearTimeout(fadeTimeoutRef.current);
|
|
}
|
|
|
|
fadeTimeoutRef.current = setTimeout(() => {
|
|
setDisplayedStatus(null);
|
|
setDisplayedPermission(false);
|
|
setIsFadingOut(false);
|
|
hasShownActivityRef.current = false;
|
|
lastActiveStatusRef.current = null;
|
|
fadeTimeoutRef.current = null;
|
|
}, 180);
|
|
}
|
|
|
|
wasAbortedRef.current = false;
|
|
};
|
|
|
|
const CHECK_THROTTLE_MS = 150; // Throttle checks to ~6-7 times per second
|
|
|
|
const hasInitialWork = Boolean(
|
|
statusText ||
|
|
displayedStatus ||
|
|
resultState !== null ||
|
|
removalPendingRef.current ||
|
|
statusQueueRef.current.length > 0 ||
|
|
wasAbortedRef.current ||
|
|
isCompleteRef.current
|
|
);
|
|
|
|
if (!hasInitialWork) {
|
|
if (rafIdRef.current !== null) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const checkLoop = (timestamp: number) => {
|
|
if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) {
|
|
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
return;
|
|
}
|
|
lastCheckTimeRef.current = timestamp;
|
|
|
|
const now = Date.now();
|
|
const elapsed = now - displayStartTimeRef.current;
|
|
|
|
const isDone = removalPendingRef.current && isCompleteRef.current;
|
|
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
|
|
|
|
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
|
|
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
return;
|
|
}
|
|
|
|
if (removalPendingRef.current && wasAbortedRef.current) {
|
|
removalPendingRef.current = false;
|
|
statusQueueRef.current = [];
|
|
startFadeOut('aborted');
|
|
} else if (!isDone && statusQueueRef.current.length > 0) {
|
|
const latest = statusQueueRef.current[statusQueueRef.current.length - 1];
|
|
activateStatus(latest.status, latest.permission);
|
|
displayStartTimeRef.current = now;
|
|
statusQueueRef.current = [];
|
|
} else if (removalPendingRef.current) {
|
|
|
|
removalPendingRef.current = false;
|
|
|
|
if (statusQueueRef.current.length > 0) {
|
|
hasShownActivityRef.current = true;
|
|
}
|
|
statusQueueRef.current = [];
|
|
|
|
let result: ResultState = null;
|
|
if (wasAbortedRef.current) {
|
|
result = 'aborted';
|
|
} else if (isCompleteRef.current) {
|
|
result = 'success';
|
|
|
|
hasShownActivityRef.current = true;
|
|
}
|
|
|
|
if (result === 'success' && completionId && lastCompletionShownRef.current === completionId) {
|
|
setDisplayedStatus(null);
|
|
setDisplayedPermission(false);
|
|
setIsFadingOut(false);
|
|
setIsVisible(false);
|
|
setResultState(null);
|
|
statusQueueRef.current = [];
|
|
hasShownActivityRef.current = false;
|
|
lastActiveStatusRef.current = null;
|
|
removalPendingRef.current = false;
|
|
wasAbortedRef.current = false;
|
|
rafIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
startFadeOut(result);
|
|
}
|
|
|
|
const hasPendingWork = Boolean(
|
|
displayedStatus ||
|
|
resultState !== null ||
|
|
statusQueueRef.current.length > 0 ||
|
|
removalPendingRef.current ||
|
|
wasAbortedRef.current ||
|
|
isCompleteRef.current
|
|
);
|
|
|
|
if (hasPendingWork) {
|
|
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
} else {
|
|
rafIdRef.current = null;
|
|
}
|
|
};
|
|
|
|
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
|
|
return () => {
|
|
if (rafIdRef.current !== null) {
|
|
cancelAnimationFrame(rafIdRef.current);
|
|
rafIdRef.current = null;
|
|
}
|
|
};
|
|
|
|
}, [statusText, displayedStatus, resultState, isComplete, wasAborted, isFadingOut]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (fadeTimeoutRef.current) {
|
|
clearTimeout(fadeTimeoutRef.current);
|
|
}
|
|
if (resultTimeoutRef.current) {
|
|
clearTimeout(resultTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
windowFocusRef.current = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
|
|
? document.hasFocus()
|
|
: true;
|
|
|
|
const handleFocus = () => {
|
|
windowFocusRef.current = true;
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
windowFocusRef.current = false;
|
|
};
|
|
|
|
window.addEventListener('focus', handleFocus);
|
|
window.addEventListener('blur', handleBlur);
|
|
|
|
return () => {
|
|
window.removeEventListener('focus', handleFocus);
|
|
window.removeEventListener('blur', handleBlur);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleVisibilityRestore = () => {
|
|
if (typeof document === 'undefined' || typeof Date === 'undefined') {
|
|
return;
|
|
}
|
|
if (document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
|
|
const shownAt = resultShownAtRef.current;
|
|
const isCompletionVisible = resultState !== null || displayedStatus !== null;
|
|
|
|
if (isCompletionVisible && shownAt && Date.now() - shownAt > 500) {
|
|
setDisplayedStatus(null);
|
|
setDisplayedPermission(false);
|
|
setIsFadingOut(false);
|
|
setIsVisible(false);
|
|
setResultState(null);
|
|
statusQueueRef.current = [];
|
|
hasShownActivityRef.current = false;
|
|
lastActiveStatusRef.current = null;
|
|
removalPendingRef.current = false;
|
|
wasAbortedRef.current = false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityRestore);
|
|
window.addEventListener('focus', handleVisibilityRestore);
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', handleVisibilityRestore);
|
|
window.removeEventListener('focus', handleVisibilityRestore);
|
|
};
|
|
}, [displayedStatus, resultState]);
|
|
|
|
if (!displayedStatus && resultState === null) {
|
|
return null;
|
|
}
|
|
|
|
let label: string;
|
|
if (resultState === 'success') {
|
|
label = 'Done';
|
|
} else if (resultState === 'aborted') {
|
|
label = 'Aborted';
|
|
} else if (displayedStatus) {
|
|
label = displayedStatus.charAt(0).toUpperCase() + displayedStatus.slice(1);
|
|
} else {
|
|
label = 'Working';
|
|
}
|
|
|
|
const ariaLive = displayedPermission ? 'assertive' : 'polite';
|
|
|
|
const displayText = resultState === null ? `${label}...` : label;
|
|
|
|
return (
|
|
<div
|
|
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible && !isFadingOut ? 'opacity-100' : 'opacity-0'}`}
|
|
role="status"
|
|
aria-live={ariaLive}
|
|
aria-label={label}
|
|
data-waiting={displayedPermission ? 'true' : undefined}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
{resultState === null && (
|
|
<Text
|
|
variant="shine"
|
|
className="typography-ui-header"
|
|
>
|
|
{displayText}
|
|
</Text>
|
|
)}
|
|
{resultState === 'success' && (
|
|
<Text
|
|
variant="hover-enter"
|
|
className="typography-ui-header"
|
|
>
|
|
Done
|
|
</Text>
|
|
)}
|
|
{resultState === 'aborted' && (
|
|
<Text
|
|
variant="hover-enter"
|
|
className="typography-ui-header text-status-error"
|
|
>
|
|
Aborted
|
|
</Text>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|