diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 1e2755036..a9fd51739 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -2,7 +2,7 @@ import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -29,6 +29,7 @@ import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; import MobileReviewSettingsDrawer, { DrawerFeatures, } from "../overlay/MobileReviewSettingsDrawer"; +import useOptimisticState from "@/hooks/use-optimistic-state"; const REVIEW_FILTERS = [ "cameras", @@ -631,12 +632,10 @@ function ShowMotionOnlyButton({ motionOnly, setMotionOnly, }: ShowMotionOnlyButtonProps) { - const [motionOnlyButton, setMotionOnlyButton] = useState(motionOnly); - - useEffect(() => { - const timeoutId = setTimeout(() => setMotionOnly(motionOnlyButton), 10); - return () => clearTimeout(timeoutId); - }, [motionOnlyButton, setMotionOnly]); + const [motionOnlyButton, setMotionOnlyButton] = useOptimisticState( + motionOnly, + setMotionOnly, + ); return ( <> diff --git a/web/src/hooks/use-optimistic-state.ts b/web/src/hooks/use-optimistic-state.ts new file mode 100644 index 000000000..ca33fe885 --- /dev/null +++ b/web/src/hooks/use-optimistic-state.ts @@ -0,0 +1,43 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +type OptimisticStateResult = [T, (newValue: T) => void]; + +const useOptimisticState = ( + initialState: T, + setState: (newValue: T) => void, + delay: number = 20, +): OptimisticStateResult => { + const [optimisticValue, setOptimisticValue] = useState(initialState); + const debounceTimeout = useRef | null>(null); + + const handleValueChange = useCallback( + (newValue: T) => { + // Update the optimistic value immediately + setOptimisticValue(newValue); + + // Clear any pending debounce timeout + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + + // Set a new debounce timeout + debounceTimeout.current = setTimeout(() => { + // Update the actual value using the provided setter function + setState(newValue); + }, delay); + }, + [delay, setState], + ); + + useEffect(() => { + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + }; + }, []); + + return [optimisticValue, handleValueChange]; +}; + +export default useOptimisticState; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 05cfa182c..0be2ccecd 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -41,6 +41,7 @@ import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; import { TimeRange } from "@/types/timeline"; import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; +import useOptimisticState from "@/hooks/use-optimistic-state"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -199,6 +200,10 @@ export default function EventView({ ); const [motionOnly, setMotionOnly] = useState(false); + const [severityToggle, setSeverityToggle] = useOptimisticState( + severity, + setSeverity, + ); if (!config) { return ; @@ -214,9 +219,9 @@ export default function EventView({ className="*:px-3 *:py-4 *:rounded-md" type="single" size="sm" - value={severity} + value={severityToggle} onValueChange={(value: ReviewSeverity) => - value ? setSeverity(value) : null + value ? setSeverityToggle(value) : null } // don't allow the severity to be unselected >