diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 23c18012..e5e79a2b 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -121,6 +121,10 @@ export const ModerationQueue = forwardRef((props, ref) => { const preserveInteraction = getPreserveInteractionState(); const useRealtimeQueue = getUseRealtimeQueue(); + // Track recently removed items to prevent realtime override of optimistic updates + const recentlyRemovedRef = useRef>(new Set()); + const prevLocksRef = useRef>(new Map()); + // Store admin settings and stable refs to avoid triggering fetchItems recreation const refreshStrategyRef = useRef(refreshStrategy); const preserveInteractionRef = useRef(preserveInteraction); @@ -574,7 +578,7 @@ export const ModerationQueue = forwardRef((props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]); - // Real-time subscription for lock status + // Real-time subscription for lock status (optimized to prevent unnecessary updates) useEffect(() => { if (!user) return; @@ -589,24 +593,21 @@ export const ModerationQueue = forwardRef((props, ref) => { }, (payload) => { const newData = payload.new as any; + const isLocked = newData.assigned_to && newData.assigned_to !== user.id && + newData.locked_until && new Date(newData.locked_until) > new Date(); + const wasLocked = prevLocksRef.current.get(newData.id) || false; - // Track submissions locked by others - if (newData.assigned_to && newData.assigned_to !== user.id && newData.locked_until) { - const lockExpiry = new Date(newData.locked_until); - if (lockExpiry > new Date()) { - setLockedSubmissions((prev) => new Set(prev).add(newData.id)); - } else { - setLockedSubmissions((prev) => { - const next = new Set(prev); - next.delete(newData.id); - return next; - }); - } - } else { - // Lock released + // Only update if lock state actually changed + if (isLocked !== wasLocked) { + prevLocksRef.current.set(newData.id, isLocked); + setLockedSubmissions((prev) => { const next = new Set(prev); - next.delete(newData.id); + if (isLocked) { + next.add(newData.id); + } else { + next.delete(newData.id); + } return next; }); } @@ -635,6 +636,11 @@ export const ModerationQueue = forwardRef((props, ref) => { async (payload) => { const newSubmission = payload.new as any; + // Ignore if recently removed (optimistic update) + if (recentlyRemovedRef.current.has(newSubmission.id)) { + return; + } + // Only process pending/partially_approved submissions if (!['pending', 'partially_approved'].includes(newSubmission.status)) { return; @@ -857,14 +863,22 @@ export const ModerationQueue = forwardRef((props, ref) => { const shouldRemove = (activeStatusFilter === 'pending' || activeStatusFilter === 'flagged') && (action === 'approved' || action === 'rejected'); - // Optimistic update - if (shouldRemove) { - setItems(prev => prev.filter(i => i.id !== item.id)); - } else { - setItems(prev => prev.map(i => - i.id === item.id ? { ...i, status: action } : i - )); - } + // Optimistic UI update - batch with requestAnimationFrame for smoother rendering + requestAnimationFrame(() => { + if (shouldRemove) { + setItems(prev => prev.filter(i => i.id !== item.id)); + + // Mark as recently removed - ignore realtime updates for 3 seconds + recentlyRemovedRef.current.add(item.id); + setTimeout(() => { + recentlyRemovedRef.current.delete(item.id); + }, 3000); + } else { + setItems(prev => prev.map(i => + i.id === item.id ? { ...i, status: action } : i + )); + } + }); // Release lock if this submission is claimed by current user if (queue.currentLock?.submissionId === item.id) { @@ -1431,13 +1445,20 @@ export const ModerationQueue = forwardRef((props, ref) => { return (
{items.map((item) => ( - + }`} + style={{ + opacity: actionLoading === item.id ? 0.5 : 1, + pointerEvents: actionLoading === item.id ? 'none' : 'auto' + }} + >
diff --git a/src/hooks/useModerationStats.ts b/src/hooks/useModerationStats.ts index 71dd2dd8..874d8c31 100644 --- a/src/hooks/useModerationStats.ts +++ b/src/hooks/useModerationStats.ts @@ -34,6 +34,7 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => { const [isInitialLoad, setIsInitialLoad] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); const onStatsChangeRef = useRef(onStatsChange); + const statsDebounceRef = useRef(null); // Update ref when callback changes useEffect(() => { @@ -93,27 +94,35 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => { } }, [enabled, fetchStats]); + // Debounced stats fetch to prevent rapid-fire updates + const debouncedFetchStats = useCallback(() => { + if (statsDebounceRef.current) { + clearTimeout(statsDebounceRef.current); + } + + statsDebounceRef.current = setTimeout(() => { + fetchStats(true); // Silent refresh + }, 500); // 500ms debounce + }, [fetchStats]); + // Realtime subscription for instant stat updates useEffect(() => { if (!enabled || !realtimeEnabled) return; const channel = supabase .channel('moderation-stats-realtime') - .on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, () => { - fetchStats(true); // Silent refresh - }) - .on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, () => { - fetchStats(true); - }) - .on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, () => { - fetchStats(true); - }) + .on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, debouncedFetchStats) + .on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, debouncedFetchStats) + .on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, debouncedFetchStats) .subscribe(); return () => { supabase.removeChannel(channel); + if (statsDebounceRef.current) { + clearTimeout(statsDebounceRef.current); + } }; - }, [enabled, realtimeEnabled, fetchStats]); + }, [enabled, realtimeEnabled, debouncedFetchStats]); // Polling (fallback when realtime is disabled) useEffect(() => {