Rewrite websocket to use tracked state instead of context (#10091)

* Rewrite websocket to use tracked state instead of context

* Cleanup

* Use component for updating items

* Fix scroll update

* Don't save vite
This commit is contained in:
Nicolas Mowen 2024-02-27 09:05:28 -07:00 committed by GitHub
parent f95ce913b1
commit 21defbea9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 210 additions and 216 deletions

48
web/package-lock.json generated
View File

@ -46,6 +46,7 @@
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-tracked": "^1.7.11",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0", "react-use-websocket": "^4.5.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
@ -6498,6 +6499,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/proxy-compare": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.4.0.tgz",
"integrity": "sha512-FD8KmQUQD6Mfpd0hywCOzcon/dbkFP8XBd9F1ycbKtvVsfv6TsFUKJ2eC0Iz2y+KzlkdT1Z8SY6ZSgm07zOyqg=="
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -6729,6 +6735,29 @@
} }
} }
}, },
"node_modules/react-tracked": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.11.tgz",
"integrity": "sha512-+XXv4dJH7NnLtSD/cPVL9omra4A3KRK91L33owevXZ81r7qF/a9DdCsVZa90jMGht/V1Ym9sasbmidsJykhULQ==",
"dependencies": {
"proxy-compare": "2.4.0",
"use-context-selector": "1.4.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": "*",
"react-native": "*",
"scheduler": ">=0.19.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -7918,6 +7947,25 @@
} }
} }
}, },
"node_modules/use-context-selector": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.1.tgz",
"integrity": "sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": "*",
"react-native": "*",
"scheduler": ">=0.19.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/use-sidecar": { "node_modules/use-sidecar": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",

View File

@ -51,6 +51,7 @@
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"react-icons": "^4.12.0", "react-icons": "^4.12.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-tracked": "^1.7.11",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use-websocket": "^4.5.0", "react-use-websocket": "^4.5.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",

View File

@ -1,9 +1,8 @@
import { baseUrl } from "./baseUrl"; import { baseUrl } from "./baseUrl";
import useSWR, { SWRConfig } from "swr"; import { SWRConfig } from "swr";
import { WsProvider } from "./ws"; import { WsProvider } from "./ws";
import axios from "axios"; import axios from "axios";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
@ -38,9 +37,7 @@ type WsWithConfigType = {
}; };
function WsWithConfig({ children }: WsWithConfigType) { function WsWithConfig({ children }: WsWithConfigType) {
const { data } = useSWR<FrigateConfig>("config"); return <WsProvider>{children}</WsProvider>;
return data ? <WsProvider config={data}>{children}</WsProvider> : children;
} }
export function useApiHost() { export function useApiHost() {

View File

@ -1,149 +1,104 @@
import { baseUrl } from "./baseUrl"; import { baseUrl } from "./baseUrl";
import { import { useCallback, useEffect, useState } from "react";
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from "react";
import { produce, Draft } from "immer";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws"; import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import useSWR from "swr";
import { createContainer } from "react-tracked";
type ReducerState = { type Update = {
[topic: string]: {
lastUpdate: number;
payload: any;
retain: boolean;
};
};
type ReducerAction = {
topic: string; topic: string;
payload: any; payload: any;
retain: boolean; retain: boolean;
}; };
const initialState: ReducerState = { type WsState = {
_initial_state: { [topic: string]: any;
lastUpdate: 0,
payload: "",
retain: false,
},
}; };
type WebSocketContextProps = { type useValueReturn = [WsState, (update: Update) => void];
state: ReducerState;
readyState: ReadyState;
sendJsonMessage: (message: any) => void;
};
export const WS = createContext<WebSocketContextProps>({ function useValue(): useValueReturn {
state: initialState, // basic config
readyState: ReadyState.CLOSED, const { data: config } = useSWR<FrigateConfig>("config", {
sendJsonMessage: () => {}, revalidateOnFocus: false,
}); });
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
export const useWebSocketContext = (): WebSocketContextProps => { // main state
const context = useContext(WS); const [wsState, setWsState] = useState<WsState>({});
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider"
);
}
return context;
};
function reducer(state: ReducerState, action: ReducerAction): ReducerState { useEffect(() => {
switch (action.topic) { if (!config) {
default: return;
return produce(state, (draftState: Draft<ReducerState>) => { }
let parsedPayload = action.payload;
try {
parsedPayload = action.payload && JSON.parse(action.payload);
} catch (e) {}
draftState[action.topic] = {
lastUpdate: Date.now(),
payload: parsedPayload,
retain: action.retain,
};
});
}
}
type WsProviderType = { const cameraStates: WsState = {};
config: FrigateConfig;
children: ReactNode;
wsUrl?: string;
};
export function WsProvider({ Object.keys(config.cameras).forEach((camera) => {
config, const { name, record, detect, snapshots, audio } = config.cameras[camera];
children, cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF";
wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`, cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF";
}: WsProviderType) { cameraStates[`${name}/snapshots/state`] = snapshots.enabled
const [state, dispatch] = useReducer(reducer, initialState); ? "ON"
: "OFF";
cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF";
});
setWsState({ ...wsState, ...cameraStates });
}, [config]);
// ws handler
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
onMessage: (event) => { onMessage: (event) => {
dispatch(JSON.parse(event.data)); const data: Update = JSON.parse(event.data);
if (data) {
setWsState({ ...wsState, [data.topic]: data.payload });
}
}, },
onOpen: () => dispatch({ topic: "", payload: "", retain: false }), onOpen: () => {},
shouldReconnect: () => true, shouldReconnect: () => true,
}); });
useEffect(() => { const setState = useCallback(
Object.keys(config.cameras).forEach((camera) => { (message: Update) => {
const { name, record, detect, snapshots, audio } = config.cameras[camera];
dispatch({
topic: `${name}/recordings/state`,
payload: record.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/detect/state`,
payload: detect.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/snapshots/state`,
payload: snapshots.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/audio/state`,
payload: audio.enabled ? "ON" : "OFF",
retain: false,
});
});
}, [config]);
return (
<WS.Provider value={{ state, readyState, sendJsonMessage }}>
{children}
</WS.Provider>
);
}
export function useWs(watchTopic: string, publishTopic: string) {
const { state, readyState, sendJsonMessage } = useWebSocketContext();
const value = state[watchTopic] || { payload: null };
const send = useCallback(
(payload: any, retain = false) => {
if (readyState === ReadyState.OPEN) { if (readyState === ReadyState.OPEN) {
sendJsonMessage({ sendJsonMessage({
topic: publishTopic || watchTopic, topic: message.topic,
payload, payload: message.payload,
retain, retain: message.retain,
}); });
} }
}, },
[sendJsonMessage, readyState, watchTopic, publishTopic] [readyState, sendJsonMessage]
);
return [wsState, setState];
}
export const {
Provider: WsProvider,
useTrackedState: useWsState,
useUpdate: useWsUpdate,
} = createContainer(useValue, { defaultState: {}, concurrentMode: true });
export function useWs(watchTopic: string, publishTopic: string) {
const state = useWsState();
const sendJsonMessage = useWsUpdate();
const value = { payload: state[watchTopic] || null };
const send = useCallback(
(payload: any, retain = false) => {
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
},
[sendJsonMessage, watchTopic, publishTopic]
); );
return { value, send }; return { value, send };
@ -219,21 +174,21 @@ export function useFrigateEvents(): { payload: FrigateEvent } {
const { const {
value: { payload }, value: { payload },
} = useWs("events", ""); } = useWs("events", "");
return { payload }; return { payload: JSON.parse(payload) };
} }
export function useFrigateReviews(): { payload: FrigateReview } { export function useFrigateReviews(): { payload: FrigateReview } {
const { const {
value: { payload }, value: { payload },
} = useWs("reviews", ""); } = useWs("reviews", "");
return { payload }; return { payload: JSON.parse(payload) };
} }
export function useFrigateStats(): { payload: FrigateStats } { export function useFrigateStats(): { payload: FrigateStats } {
const { const {
value: { payload }, value: { payload },
} = useWs("stats", ""); } = useWs("stats", "");
return { payload }; return { payload: JSON.parse(payload) };
} }
export function useMotionActivity(camera: string): { payload: string } { export function useMotionActivity(camera: string): { payload: string } {

View File

@ -0,0 +1,67 @@
import { useFrigateReviews } from "@/api/ws";
import { ReviewSeverity } from "@/types/review";
import { Button } from "../ui/button";
import { LuRefreshCcw } from "react-icons/lu";
import { MutableRefObject, useEffect, useState } from "react";
type NewReviewDataProps = {
className: string;
contentRef: MutableRefObject<HTMLDivElement | null>;
severity: ReviewSeverity;
pullLatestData: () => void;
};
export default function NewReviewData({
className,
contentRef,
severity,
pullLatestData,
}: NewReviewDataProps) {
const { payload: review } = useFrigateReviews();
const [reviewId, setReviewId] = useState("");
const [hasUpdate, setHasUpdate] = useState(false);
useEffect(() => {
if (!review) {
return;
}
if (review.type == "end" && review.review.severity == severity) {
setReviewId(review.review.id);
}
}, [review]);
useEffect(() => {
if (reviewId != "") {
setHasUpdate(true);
}
}, [reviewId]);
return (
<div className={className}>
<div className="flex justify-center items-center mr-[100px]">
<Button
className={`${
hasUpdate
? "animate-in slide-in-from-top duration-500"
: "invisible"
} text-center mt-5 mx-auto bg-gray-400 text-white`}
variant="secondary"
onClick={() => {
setHasUpdate(false);
pullLatestData();
if (contentRef.current) {
contentRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
>
<LuRefreshCcw className="w-4 h-4 mr-2" />
New Items To Review
</Button>
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useFrigateReviews } from "@/api/ws";
import useApiFilter from "@/hooks/use-api-filter"; import useApiFilter from "@/hooks/use-api-filter";
import useOverlayState from "@/hooks/use-overlay-state"; import useOverlayState from "@/hooks/use-overlay-state";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
@ -6,7 +5,7 @@ import DesktopEventView from "@/views/events/DesktopEventView";
import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView"; import MobileEventView from "@/views/events/MobileEventView";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
@ -102,7 +101,7 @@ export default function Events() {
const reloadData = useCallback(() => { const reloadData = useCallback(() => {
setSize(1); setSize(1);
updateSegments(); updateSegments();
}, []) }, []);
// preview videos // preview videos
@ -201,24 +200,6 @@ export default function Events() {
}; };
}, [selectedReviewId, reviewPages]); }, [selectedReviewId, reviewPages]);
// review updates
const { payload: reviewUpdate } = useFrigateReviews();
const [hasUpdate, setHasUpdate] = useState(false);
useEffect(() => {
if (!reviewUpdate || hasUpdate) {
return;
}
if (
reviewUpdate.type == "end" &&
reviewUpdate.review.severity == severity
) {
setHasUpdate(true);
return;
}
}, [reviewUpdate]);
if (selectedData) { if (selectedData) {
return ( return (
<DesktopRecordingView <DesktopRecordingView
@ -236,9 +217,7 @@ export default function Events() {
reachedEnd={isDone} reachedEnd={isDone}
isValidating={isValidating} isValidating={isValidating}
severity={severity} severity={severity}
hasUpdate={hasUpdate}
setSeverity={setSeverity} setSeverity={setSeverity}
setHasUpdate={setHasUpdate}
loadNextPage={onLoadNextPage} loadNextPage={onLoadNextPage}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
pullLatestData={reloadData} pullLatestData={reloadData}
@ -255,9 +234,7 @@ export default function Events() {
isValidating={isValidating} isValidating={isValidating}
filter={reviewFilter} filter={reviewFilter}
severity={severity} severity={severity}
hasUpdate={hasUpdate}
setSeverity={setSeverity} setSeverity={setSeverity}
setHasUpdate={setHasUpdate}
loadNextPage={onLoadNextPage} loadNextPage={onLoadNextPage}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
onSelectReview={setSelectedReviewId} onSelectReview={setSelectedReviewId}

View File

@ -1,13 +1,13 @@
import NewReviewData from "@/components/dynamic/NewReviewData";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuFolderCheck, LuRefreshCcw } from "react-icons/lu"; import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
@ -19,9 +19,7 @@ type DesktopEventViewProps = {
isValidating: boolean; isValidating: boolean;
filter?: ReviewFilter; filter?: ReviewFilter;
severity: ReviewSeverity; severity: ReviewSeverity;
hasUpdate: boolean;
setSeverity: (severity: ReviewSeverity) => void; setSeverity: (severity: ReviewSeverity) => void;
setHasUpdate: (hasUpdated: boolean) => void;
loadNextPage: () => void; loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void; markItemAsReviewed: (reviewId: string) => void;
onSelectReview: (reviewId: string) => void; onSelectReview: (reviewId: string) => void;
@ -36,9 +34,7 @@ export default function DesktopEventView({
isValidating, isValidating,
filter, filter,
severity, severity,
hasUpdate,
setSeverity, setSeverity,
setHasUpdate,
loadNextPage, loadNextPage,
markItemAsReviewed, markItemAsReviewed,
onSelectReview, onSelectReview,
@ -98,7 +94,7 @@ export default function DesktopEventView({
} }
return contentRef.current.scrollHeight > contentRef.current.clientHeight; return contentRef.current.scrollHeight > contentRef.current.clientHeight;
}, [contentRef.current?.scrollHeight]); }, [contentRef.current?.scrollHeight, severity]);
// review interaction // review interaction
@ -235,33 +231,12 @@ export default function DesktopEventView({
ref={contentRef} ref={contentRef}
className="flex flex-1 flex-wrap content-start gap-2 overflow-y-auto no-scrollbar" className="flex flex-1 flex-wrap content-start gap-2 overflow-y-auto no-scrollbar"
> >
{hasUpdate && ( <NewReviewData
<div className="absolute w-full z-30"> className="absolute w-full z-30"
<div className="flex justify-center items-center mr-[100px]"> contentRef={contentRef}
<Button severity={severity}
className={`${ pullLatestData={pullLatestData}
hasUpdate />
? "animate-in slide-in-from-top duration-500"
: "invisible"
} text-center mt-5 mx-auto bg-gray-400 text-white`}
variant="secondary"
onClick={() => {
setHasUpdate(false);
pullLatestData();
if (contentRef.current) {
contentRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
>
<LuRefreshCcw className="w-4 h-4 mr-2" />
New Items To Review
</Button>
</div>
</div>
)}
{reachedEnd && currentItems == null && ( {reachedEnd && currentItems == null && (
<div className="w-full h-full flex flex-col justify-center items-center"> <div className="w-full h-full flex flex-col justify-center items-center">

View File

@ -1,11 +1,10 @@
import NewReviewData from "@/components/dynamic/NewReviewData";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuRefreshCcw } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
@ -15,9 +14,7 @@ type MobileEventViewProps = {
reachedEnd: boolean; reachedEnd: boolean;
isValidating: boolean; isValidating: boolean;
severity: ReviewSeverity; severity: ReviewSeverity;
hasUpdate: boolean;
setSeverity: (severity: ReviewSeverity) => void; setSeverity: (severity: ReviewSeverity) => void;
setHasUpdate: (hasUpdated: boolean) => void;
loadNextPage: () => void; loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void; markItemAsReviewed: (reviewId: string) => void;
pullLatestData: () => void; pullLatestData: () => void;
@ -28,9 +25,7 @@ export default function MobileEventView({
reachedEnd, reachedEnd,
isValidating, isValidating,
severity, severity,
hasUpdate,
setSeverity, setSeverity,
setHasUpdate,
loadNextPage, loadNextPage,
markItemAsReviewed, markItemAsReviewed,
pullLatestData, pullLatestData,
@ -209,33 +204,12 @@ export default function MobileEventView({
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
{hasUpdate && ( <NewReviewData
<div className="absolute w-full z-30"> className="absolute w-full z-30"
<div className="flex justify-center items-center"> contentRef={contentRef}
<Button severity={severity}
className={`${ pullLatestData={pullLatestData}
hasUpdate />
? "animate-in slide-in-from-top duration-500"
: "invisible"
} text-center mt-5 mx-auto bg-gray-400 text-white`}
variant="secondary"
onClick={() => {
setHasUpdate(false);
pullLatestData();
if (contentRef.current) {
contentRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
>
<LuRefreshCcw className="w-4 h-4 mr-2" />
New Items To Review
</Button>
</div>
</div>
)}
<div <div
ref={contentRef} ref={contentRef}