From 21defbea9dde9e8c1191191c56ce0da914736600 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 27 Feb 2024 09:05:28 -0700 Subject: [PATCH] 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 --- web/package-lock.json | 48 +++++ web/package.json | 1 + web/src/api/index.tsx | 7 +- web/src/api/ws.tsx | 193 +++++++------------ web/src/components/dynamic/NewReviewData.tsx | 67 +++++++ web/src/pages/Events.tsx | 27 +-- web/src/views/events/DesktopEventView.tsx | 43 +---- web/src/views/events/MobileEventView.tsx | 40 +--- 8 files changed, 210 insertions(+), 216 deletions(-) create mode 100644 web/src/components/dynamic/NewReviewData.tsx diff --git a/web/package-lock.json b/web/package-lock.json index f896f7c8a..13be9ab59 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -46,6 +46,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.12.0", "react-router-dom": "^6.20.1", + "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.5.0", "recoil": "^0.7.7", @@ -6498,6 +6499,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "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": { "version": "1.1.0", "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": { "version": "4.4.5", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 5250d3740..953a4b875 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.12.0", "react-router-dom": "^6.20.1", + "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.5.0", "recoil": "^0.7.7", diff --git a/web/src/api/index.tsx b/web/src/api/index.tsx index 1f59ed71c..ea6a25a6c 100644 --- a/web/src/api/index.tsx +++ b/web/src/api/index.tsx @@ -1,9 +1,8 @@ import { baseUrl } from "./baseUrl"; -import useSWR, { SWRConfig } from "swr"; +import { SWRConfig } from "swr"; import { WsProvider } from "./ws"; import axios from "axios"; import { ReactNode } from "react"; -import { FrigateConfig } from "@/types/frigateConfig"; axios.defaults.baseURL = `${baseUrl}api/`; @@ -38,9 +37,7 @@ type WsWithConfigType = { }; function WsWithConfig({ children }: WsWithConfigType) { - const { data } = useSWR("config"); - - return data ? {children} : children; + return {children}; } export function useApiHost() { diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 88091a915..5c01e0341 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -1,149 +1,104 @@ import { baseUrl } from "./baseUrl"; -import { - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useReducer, -} from "react"; -import { produce, Draft } from "immer"; +import { useCallback, useEffect, useState } from "react"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws"; import { FrigateStats } from "@/types/stats"; +import useSWR from "swr"; +import { createContainer } from "react-tracked"; -type ReducerState = { - [topic: string]: { - lastUpdate: number; - payload: any; - retain: boolean; - }; -}; - -type ReducerAction = { +type Update = { topic: string; payload: any; retain: boolean; }; -const initialState: ReducerState = { - _initial_state: { - lastUpdate: 0, - payload: "", - retain: false, - }, +type WsState = { + [topic: string]: any; }; -type WebSocketContextProps = { - state: ReducerState; - readyState: ReadyState; - sendJsonMessage: (message: any) => void; -}; +type useValueReturn = [WsState, (update: Update) => void]; -export const WS = createContext({ - state: initialState, - readyState: ReadyState.CLOSED, - sendJsonMessage: () => {}, -}); +function useValue(): useValueReturn { + // basic config + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; -export const useWebSocketContext = (): WebSocketContextProps => { - const context = useContext(WS); - if (!context) { - throw new Error( - "useWebSocketContext must be used within a WebSocketProvider" - ); - } - return context; -}; + // main state + const [wsState, setWsState] = useState({}); -function reducer(state: ReducerState, action: ReducerAction): ReducerState { - switch (action.topic) { - default: - return produce(state, (draftState: Draft) => { - 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, - }; - }); - } -} + useEffect(() => { + if (!config) { + return; + } -type WsProviderType = { - config: FrigateConfig; - children: ReactNode; - wsUrl?: string; -}; + const cameraStates: WsState = {}; -export function WsProvider({ - config, - children, - wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`, -}: WsProviderType) { - const [state, dispatch] = useReducer(reducer, initialState); + Object.keys(config.cameras).forEach((camera) => { + const { name, record, detect, snapshots, audio } = config.cameras[camera]; + cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF"; + cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF"; + cameraStates[`${name}/snapshots/state`] = snapshots.enabled + ? "ON" + : "OFF"; + cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF"; + }); + setWsState({ ...wsState, ...cameraStates }); + }, [config]); + + // ws handler const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { 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, }); - useEffect(() => { - Object.keys(config.cameras).forEach((camera) => { - 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 ( - - {children} - - ); -} - -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) => { + const setState = useCallback( + (message: Update) => { if (readyState === ReadyState.OPEN) { sendJsonMessage({ - topic: publishTopic || watchTopic, - payload, - retain, + topic: message.topic, + payload: message.payload, + 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 }; @@ -219,21 +174,21 @@ export function useFrigateEvents(): { payload: FrigateEvent } { const { value: { payload }, } = useWs("events", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useFrigateReviews(): { payload: FrigateReview } { const { value: { payload }, } = useWs("reviews", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useFrigateStats(): { payload: FrigateStats } { const { value: { payload }, } = useWs("stats", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useMotionActivity(camera: string): { payload: string } { diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx new file mode 100644 index 000000000..89bb2d467 --- /dev/null +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -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; + 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 ( +
+
+ +
+
+ ); +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 40d71adb1..e7f0b456d 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,4 +1,3 @@ -import { useFrigateReviews } from "@/api/ws"; import useApiFilter from "@/hooks/use-api-filter"; import useOverlayState from "@/hooks/use-overlay-state"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; @@ -6,7 +5,7 @@ import DesktopEventView from "@/views/events/DesktopEventView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import MobileEventView from "@/views/events/MobileEventView"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; @@ -102,7 +101,7 @@ export default function Events() { const reloadData = useCallback(() => { setSize(1); updateSegments(); - }, []) + }, []); // preview videos @@ -201,24 +200,6 @@ export default function Events() { }; }, [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) { return ( void; - setHasUpdate: (hasUpdated: boolean) => void; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; onSelectReview: (reviewId: string) => void; @@ -36,9 +34,7 @@ export default function DesktopEventView({ isValidating, filter, severity, - hasUpdate, setSeverity, - setHasUpdate, loadNextPage, markItemAsReviewed, onSelectReview, @@ -98,7 +94,7 @@ export default function DesktopEventView({ } return contentRef.current.scrollHeight > contentRef.current.clientHeight; - }, [contentRef.current?.scrollHeight]); + }, [contentRef.current?.scrollHeight, severity]); // review interaction @@ -235,33 +231,12 @@ export default function DesktopEventView({ ref={contentRef} className="flex flex-1 flex-wrap content-start gap-2 overflow-y-auto no-scrollbar" > - {hasUpdate && ( -
-
- -
-
- )} + {reachedEnd && currentItems == null && (
diff --git a/web/src/views/events/MobileEventView.tsx b/web/src/views/events/MobileEventView.tsx index cfdce9bba..187ca398a 100644 --- a/web/src/views/events/MobileEventView.tsx +++ b/web/src/views/events/MobileEventView.tsx @@ -1,11 +1,10 @@ +import NewReviewData from "@/components/dynamic/NewReviewData"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import ActivityIndicator from "@/components/ui/activity-indicator"; -import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { LuRefreshCcw } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; @@ -15,9 +14,7 @@ type MobileEventViewProps = { reachedEnd: boolean; isValidating: boolean; severity: ReviewSeverity; - hasUpdate: boolean; setSeverity: (severity: ReviewSeverity) => void; - setHasUpdate: (hasUpdated: boolean) => void; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; pullLatestData: () => void; @@ -28,9 +25,7 @@ export default function MobileEventView({ reachedEnd, isValidating, severity, - hasUpdate, setSeverity, - setHasUpdate, loadNextPage, markItemAsReviewed, pullLatestData, @@ -209,33 +204,12 @@ export default function MobileEventView({ - {hasUpdate && ( -
-
- -
-
- )} +