From fdbb0d1022c074a1b721508f29480b12255f7318 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Thu, 29 Aug 2024 19:21:02 +0530 Subject: [PATCH 1/7] Adds support for "Still watching" prompt to prevent users from idling when watching stream --- .../CentralLiveMonitoring/index.tsx | 29 ++-- src/Components/CameraFeed/StillWatching.tsx | 128 ++++++++++++++++++ .../ConsultationFeedTab.tsx | 5 +- 3 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 src/Components/CameraFeed/StillWatching.tsx diff --git a/src/Components/CameraFeed/CentralLiveMonitoring/index.tsx b/src/Components/CameraFeed/CentralLiveMonitoring/index.tsx index d2e7fd89494..3cca4c00ec4 100644 --- a/src/Components/CameraFeed/CentralLiveMonitoring/index.tsx +++ b/src/Components/CameraFeed/CentralLiveMonitoring/index.tsx @@ -8,6 +8,7 @@ import Fullscreen from "../../../CAREUI/misc/Fullscreen"; import useBreakpoints from "../../../Common/hooks/useBreakpoints"; import { useQueryParams } from "raviger"; import LiveMonitoringFilters from "./LiveMonitoringFilters"; +import StillWatching from "../StillWatching"; export default function CentralLiveMonitoring(props: { facilityId: string }) { const [isFullscreen, setFullscreen] = useState(false); @@ -59,19 +60,21 @@ export default function CentralLiveMonitoring(props: { facilityId: string }) { No Camera present in this location or facility. ) : ( - setFullscreen(false)} - > -
- {data.results.map((asset) => ( -
- -
- ))} -
-
+ + setFullscreen(false)} + > +
+ {data.results.map((asset) => ( +
+ +
+ ))} +
+
+
)} ); diff --git a/src/Components/CameraFeed/StillWatching.tsx b/src/Components/CameraFeed/StillWatching.tsx new file mode 100644 index 00000000000..5d82a17d415 --- /dev/null +++ b/src/Components/CameraFeed/StillWatching.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useState } from "react"; +import ConfirmDialog from "../Common/ConfirmDialog"; +import ButtonV2 from "../Common/components/ButtonV2"; +import CareIcon from "../../CAREUI/icons/CareIcon"; + +/** + * Calculates the linear backoff duration with saturation after a specified number of attempts. + * + * The function multiplies the `retryCount` by a `baseDelay` to calculate the backoff duration. + * If the `retryCount` exceeds `maxRetries`, the delay saturates at `baseDelay * maxRetries`. + * + * @param {number} retryCount - The current attempt number (should be non-negative). + * @param {number} [baseDelay=300000] - The base delay in milliseconds for each retry. Defaults to 5 minutes (300,000 ms). + * @param {number} [maxRetries=3] - The number of retries after which the delay saturates. Defaults to 3. + * @returns {number} The calculated delay duration in milliseconds. + */ +const calculateLinearBackoffWithSaturation = ( + retryCount: number, + baseDelay = 3 * 60e3, + maxRetries = 3, +) => { + return baseDelay * Math.min(retryCount, maxRetries); +}; + +type Props = { + children: React.ReactNode; +}; + +export default function StillWatching(props: Props) { + const [state, setState] = useState<"watching" | "prompted" | "timed-out">( + "watching", + ); + const [sequence, setSequence] = useState(1); + + const getNextTimeout = useCallback(() => { + return ( + new Date().getTime() + calculateLinearBackoffWithSaturation(sequence) + ); + }, [sequence]); + + const [timeoutOn, setTimeoutOn] = useState(getNextTimeout); + + useEffect(() => { + setTimeoutOn(getNextTimeout()); + }, [getNextTimeout]); + + useEffect(() => { + const interval = setInterval(() => { + const remainingTime = timeoutOn - new Date().getTime(); + + if (remainingTime < 0) { + setState("timed-out"); + clearInterval(interval); + return; + } + if (remainingTime < 30e3) { + setState("prompted"); + return; + } + + setState("watching"); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [timeoutOn, state]); + + return ( +
{ + if (state === "watching") { + setTimeoutOn(getNextTimeout()); + } + }} + > + + + Continue watching ( + s) + + } + onConfirm={() => setSequence((seq) => seq + 1)} + onClose={() => setSequence((seq) => seq + 1)} + /> + {state === "timed-out" ? ( + setSequence((seq) => seq + 1)} /> + ) : ( + props.children + )} +
+ ); +} + +const RemainingTime = (props: { timeoutOn: number }) => { + const [diff, setDiff] = useState(props.timeoutOn - new Date().getTime()); + + useEffect(() => { + const interval = setInterval(() => { + setDiff(props.timeoutOn - new Date().getTime()); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [props.timeoutOn]); + + return (diff / 1e3).toFixed(0); +}; + +const TimedOut = (props: { onResume: () => void }) => { + return ( +
+ + Live feed has stopped streaming due to inactivity. + + + + Resume + +
+ ); +}; diff --git a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx index 08d2b314b82..2e999d1956e 100644 --- a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx +++ b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx @@ -21,6 +21,7 @@ import useBreakpoints from "../../../Common/hooks/useBreakpoints"; import { Warn } from "../../../Utils/Notifications"; import { useTranslation } from "react-i18next"; import { GetStatusResponse } from "../../CameraFeed/routes"; +import StillWatching from "../../CameraFeed/StillWatching"; export const ConsultationFeedTab = (props: ConsultationTabProps) => { const { t } = useTranslation(); @@ -148,7 +149,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { const cannotSaveToPreset = !hasMoved || !preset?.id; return ( - <> + { - + ); }; From d2498f389d59d5d2779a51302fdf3c4c03201c59 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Fri, 30 Aug 2024 15:56:08 +0530 Subject: [PATCH 2/7] Adds i18n; make timeouts configurable; Refactor code to improve code quality --- src/CAREUI/misc/RemainingTime.tsx | 23 ++++ src/Common/hooks/useConfig.ts | 10 ++ src/Components/CameraFeed/StillWatching.tsx | 129 +++++++++----------- src/Locale/en/Asset.json | 9 +- 4 files changed, 97 insertions(+), 74 deletions(-) create mode 100644 src/CAREUI/misc/RemainingTime.tsx diff --git a/src/CAREUI/misc/RemainingTime.tsx b/src/CAREUI/misc/RemainingTime.tsx new file mode 100644 index 00000000000..818ea42718a --- /dev/null +++ b/src/CAREUI/misc/RemainingTime.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +export default function RemainingTime({ time }: { time: number }) { + const [remaining, setRemaining] = React.useState(time - new Date().getTime()); + + React.useEffect(() => { + const interval = setInterval(() => { + setRemaining(time - new Date().getTime()); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [time]); + + const seconds = remaining / 1e3; + + if (seconds < 0) { + return "0 sec."; + } + + return `${seconds.toFixed(0)}s.`; +} diff --git a/src/Common/hooks/useConfig.ts b/src/Common/hooks/useConfig.ts index 38e2336d583..92e4c31223d 100644 --- a/src/Common/hooks/useConfig.ts +++ b/src/Common/hooks/useConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from "react"; +import { StillWatchingConfig } from "../../Components/CameraFeed/StillWatching"; export const AppConfigContext = createContext(null); @@ -83,8 +84,17 @@ export interface IConfig { * Env to toggle peacetime and wartime shifting */ wartime_shifting: boolean; + + /** + * The interval at which the JWT access token is refreshed in milliseconds. + */ jwt_token_refresh_interval?: number; + /** + * Configurations related to the `StillWatching` component. + */ + still_watching?: StillWatchingConfig; + /* * Minimum date for a possible consultation encounter. */ diff --git a/src/Components/CameraFeed/StillWatching.tsx b/src/Components/CameraFeed/StillWatching.tsx index 5d82a17d415..e8c380611c0 100644 --- a/src/Components/CameraFeed/StillWatching.tsx +++ b/src/Components/CameraFeed/StillWatching.tsx @@ -2,41 +2,34 @@ import { useCallback, useEffect, useState } from "react"; import ConfirmDialog from "../Common/ConfirmDialog"; import ButtonV2 from "../Common/components/ButtonV2"; import CareIcon from "../../CAREUI/icons/CareIcon"; +import { useTranslation } from "react-i18next"; +import useConfig from "../../Common/hooks/useConfig"; +import RemainingTime from "../../CAREUI/misc/RemainingTime"; -/** - * Calculates the linear backoff duration with saturation after a specified number of attempts. - * - * The function multiplies the `retryCount` by a `baseDelay` to calculate the backoff duration. - * If the `retryCount` exceeds `maxRetries`, the delay saturates at `baseDelay * maxRetries`. - * - * @param {number} retryCount - The current attempt number (should be non-negative). - * @param {number} [baseDelay=300000] - The base delay in milliseconds for each retry. Defaults to 5 minutes (300,000 ms). - * @param {number} [maxRetries=3] - The number of retries after which the delay saturates. Defaults to 3. - * @returns {number} The calculated delay duration in milliseconds. - */ -const calculateLinearBackoffWithSaturation = ( - retryCount: number, - baseDelay = 3 * 60e3, - maxRetries = 3, -) => { - return baseDelay * Math.min(retryCount, maxRetries); +export type StillWatchingConfig = { + idleTimeout?: number; + promptDuration?: number; }; -type Props = { - children: React.ReactNode; -}; +const DEFAULT_CONFIG = { + idleTimeout: 3 * 60e3, + promptDuration: 30e3, +} satisfies StillWatchingConfig; -export default function StillWatching(props: Props) { - const [state, setState] = useState<"watching" | "prompted" | "timed-out">( - "watching", - ); +type State = "watching" | "prompted" | "timed-out"; + +const useStillWatching = (config: StillWatchingConfig) => { + const { idleTimeout, promptDuration } = { ...DEFAULT_CONFIG, ...config }; + + const [state, setState] = useState("watching"); const [sequence, setSequence] = useState(1); const getNextTimeout = useCallback(() => { return ( - new Date().getTime() + calculateLinearBackoffWithSaturation(sequence) + new Date().getTime() + + (idleTimeout + promptDuration) * Math.min(sequence, 3) ); - }, [sequence]); + }, [sequence, idleTimeout, promptDuration]); const [timeoutOn, setTimeoutOn] = useState(getNextTimeout); @@ -53,7 +46,7 @@ export default function StillWatching(props: Props) { clearInterval(interval); return; } - if (remainingTime < 30e3) { + if (remainingTime < promptDuration) { setState("prompted"); return; } @@ -64,65 +57,57 @@ export default function StillWatching(props: Props) { return () => { clearInterval(interval); }; - }, [timeoutOn, state]); + }, [timeoutOn, state, promptDuration]); + + return { + state, + timeoutOn, + reset: (hardReset?: boolean) => { + if (hardReset) { + setSequence((seq) => seq + 1); + return; + } + + if (state === "watching") { + setTimeoutOn(getNextTimeout()); + } + }, + }; +}; + +export default function StillWatching(props: { children: React.ReactNode }) { + const { t } = useTranslation(); + const { still_watching: config = {} } = useConfig(); + const { state, timeoutOn, reset } = useStillWatching(config); return ( -
{ - if (state === "watching") { - setTimeoutOn(getNextTimeout()); - } - }} - > +
reset()}> - Continue watching ( - s) + {t("continue_watching")} () } - onConfirm={() => setSequence((seq) => seq + 1)} - onClose={() => setSequence((seq) => seq + 1)} + onConfirm={() => reset(true)} + onClose={() => reset(true)} /> {state === "timed-out" ? ( - setSequence((seq) => seq + 1)} /> +
+ + {t("stream_stopped_due_to_inativity")} + + reset(true)}> + + {t("resume")} + +
) : ( props.children )}
); } - -const RemainingTime = (props: { timeoutOn: number }) => { - const [diff, setDiff] = useState(props.timeoutOn - new Date().getTime()); - - useEffect(() => { - const interval = setInterval(() => { - setDiff(props.timeoutOn - new Date().getTime()); - }, 1000); - - return () => { - clearInterval(interval); - }; - }, [props.timeoutOn]); - - return (diff / 1e3).toFixed(0); -}; - -const TimedOut = (props: { onResume: () => void }) => { - return ( -
- - Live feed has stopped streaming due to inactivity. - - - - Resume - -
- ); -}; diff --git a/src/Locale/en/Asset.json b/src/Locale/en/Asset.json index f24549ee0b6..182bb25a4fe 100644 --- a/src/Locale/en/Asset.json +++ b/src/Locale/en/Asset.json @@ -11,5 +11,10 @@ "update_asset_service_record": "Update Asset Service Record", "eg_details_on_functionality_service_etc": "Eg. Details on functionality, service, etc.", "updating": "Updating", - "update": "Update" -} + "update": "Update", + "are_you_still_watching": "Are you still watching?", + "stream_stop_due_to_inativity": "The live feed will stop streaming due to inactivity", + "stream_stopped_due_to_inativity": "The live feed has stopped streaming due to inactivity", + "continue_watching": "Continue watching", + "resume": "Resume" +} \ No newline at end of file From e1c150f20976e4310747cfb83ec3dcc3f94fa905 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Fri, 30 Aug 2024 15:58:26 +0530 Subject: [PATCH 3/7] fixed typo in remaining time --- src/CAREUI/misc/RemainingTime.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CAREUI/misc/RemainingTime.tsx b/src/CAREUI/misc/RemainingTime.tsx index 818ea42718a..75198a73a61 100644 --- a/src/CAREUI/misc/RemainingTime.tsx +++ b/src/CAREUI/misc/RemainingTime.tsx @@ -16,7 +16,7 @@ export default function RemainingTime({ time }: { time: number }) { const seconds = remaining / 1e3; if (seconds < 0) { - return "0 sec."; + return "0s."; } return `${seconds.toFixed(0)}s.`; From 2670d18329c6fce0ba3898d0b06ed12303c8f969 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Fri, 30 Aug 2024 15:59:38 +0530 Subject: [PATCH 4/7] switch to using Math.max instead of if conditions --- src/CAREUI/misc/RemainingTime.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/CAREUI/misc/RemainingTime.tsx b/src/CAREUI/misc/RemainingTime.tsx index 75198a73a61..959755cca36 100644 --- a/src/CAREUI/misc/RemainingTime.tsx +++ b/src/CAREUI/misc/RemainingTime.tsx @@ -13,11 +13,5 @@ export default function RemainingTime({ time }: { time: number }) { }; }, [time]); - const seconds = remaining / 1e3; - - if (seconds < 0) { - return "0s."; - } - - return `${seconds.toFixed(0)}s.`; + return `${Math.max(remaining / 1e3, 0).toFixed(0)}s.`; } From ba8b3ec5c82220d8c4c824cf3e0c37b344b85d57 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Sat, 31 Aug 2024 08:28:02 +0530 Subject: [PATCH 5/7] refactor to use `useTimer` --- src/CAREUI/misc/RemainingTime.tsx | 17 ----- src/Components/CameraFeed/StillWatching.tsx | 69 ++++++++------------- src/Utils/useTimer.tsx | 7 ++- 3 files changed, 30 insertions(+), 63 deletions(-) delete mode 100644 src/CAREUI/misc/RemainingTime.tsx diff --git a/src/CAREUI/misc/RemainingTime.tsx b/src/CAREUI/misc/RemainingTime.tsx deleted file mode 100644 index 959755cca36..00000000000 --- a/src/CAREUI/misc/RemainingTime.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; - -export default function RemainingTime({ time }: { time: number }) { - const [remaining, setRemaining] = React.useState(time - new Date().getTime()); - - React.useEffect(() => { - const interval = setInterval(() => { - setRemaining(time - new Date().getTime()); - }, 1000); - - return () => { - clearInterval(interval); - }; - }, [time]); - - return `${Math.max(remaining / 1e3, 0).toFixed(0)}s.`; -} diff --git a/src/Components/CameraFeed/StillWatching.tsx b/src/Components/CameraFeed/StillWatching.tsx index e8c380611c0..531119b5983 100644 --- a/src/Components/CameraFeed/StillWatching.tsx +++ b/src/Components/CameraFeed/StillWatching.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import ConfirmDialog from "../Common/ConfirmDialog"; import ButtonV2 from "../Common/components/ButtonV2"; import CareIcon from "../../CAREUI/icons/CareIcon"; import { useTranslation } from "react-i18next"; import useConfig from "../../Common/hooks/useConfig"; -import RemainingTime from "../../CAREUI/misc/RemainingTime"; +import { useTimer } from "../../Utils/useTimer"; export type StillWatchingConfig = { idleTimeout?: number; @@ -12,8 +12,8 @@ export type StillWatchingConfig = { }; const DEFAULT_CONFIG = { - idleTimeout: 3 * 60e3, - promptDuration: 30e3, + idleTimeout: 3 * 60, + promptDuration: 30, } satisfies StillWatchingConfig; type State = "watching" | "prompted" | "timed-out"; @@ -24,53 +24,36 @@ const useStillWatching = (config: StillWatchingConfig) => { const [state, setState] = useState("watching"); const [sequence, setSequence] = useState(1); - const getNextTimeout = useCallback(() => { - return ( - new Date().getTime() + - (idleTimeout + promptDuration) * Math.min(sequence, 3) - ); - }, [sequence, idleTimeout, promptDuration]); + const timer = useTimer(true); - const [timeoutOn, setTimeoutOn] = useState(getNextTimeout); - - useEffect(() => { - setTimeoutOn(getNextTimeout()); - }, [getNextTimeout]); + const remainingTime = Math.ceil( + (idleTimeout + promptDuration) * Math.min(sequence, 3) - timer.seconds, + ); useEffect(() => { - const interval = setInterval(() => { - const remainingTime = timeoutOn - new Date().getTime(); - - if (remainingTime < 0) { - setState("timed-out"); - clearInterval(interval); - return; - } - if (remainingTime < promptDuration) { - setState("prompted"); - return; - } - - setState("watching"); - }, 1000); - - return () => { - clearInterval(interval); - }; - }, [timeoutOn, state, promptDuration]); + if (remainingTime < 0) { + setState("timed-out"); + timer.stop(); + return; + } + if (remainingTime < promptDuration) { + setState("prompted"); + return; + } + }, [promptDuration, remainingTime]); + + console.log({ remainingTime, state }); return { state, - timeoutOn, + remainingTime, reset: (hardReset?: boolean) => { if (hardReset) { setSequence((seq) => seq + 1); - return; - } - - if (state === "watching") { - setTimeoutOn(getNextTimeout()); } + timer.reset(); + setState("watching"); + timer.start(); }, }; }; @@ -78,7 +61,7 @@ const useStillWatching = (config: StillWatchingConfig) => { export default function StillWatching(props: { children: React.ReactNode }) { const { t } = useTranslation(); const { still_watching: config = {} } = useConfig(); - const { state, timeoutOn, reset } = useStillWatching(config); + const { state, remainingTime, reset } = useStillWatching(config); return (
reset()}> @@ -89,7 +72,7 @@ export default function StillWatching(props: { children: React.ReactNode }) { action={ <> - {t("continue_watching")} () + {t("continue_watching")} ({remainingTime}s.) } onConfirm={() => reset(true)} diff --git a/src/Utils/useTimer.tsx b/src/Utils/useTimer.tsx index e017bf917b8..432ec508f64 100644 --- a/src/Utils/useTimer.tsx +++ b/src/Utils/useTimer.tsx @@ -22,8 +22,8 @@ import { useEffect, useState } from "react"; * // To display the timer in your component: *
{time}
*/ -export const useTimer = () => { - const [running, setRunning] = useState(false); +export const useTimer = (autoStart = false) => { + const [running, setRunning] = useState(autoStart); const [time, setTime] = useState(0); useEffect(() => { @@ -39,7 +39,7 @@ export const useTimer = () => { }, [running]); return { - seconds: time, + seconds: time / 100, time: ( {("0" + Math.floor((time / 6000) % 60)).slice(-2)}: @@ -48,5 +48,6 @@ export const useTimer = () => { ), start: () => setRunning(true), stop: () => setRunning(false), + reset: () => setTime(0), }; }; From 4ce84ac318491d594f9a36b395190be32f9f9d4c Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Sat, 31 Aug 2024 08:31:04 +0530 Subject: [PATCH 6/7] remove console.log --- src/Components/CameraFeed/StillWatching.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Components/CameraFeed/StillWatching.tsx b/src/Components/CameraFeed/StillWatching.tsx index 531119b5983..14255aa0d93 100644 --- a/src/Components/CameraFeed/StillWatching.tsx +++ b/src/Components/CameraFeed/StillWatching.tsx @@ -42,8 +42,6 @@ const useStillWatching = (config: StillWatchingConfig) => { } }, [promptDuration, remainingTime]); - console.log({ remainingTime, state }); - return { state, remainingTime, From 3b6e886243d211a21f45628c155eeba02db721cf Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 3 Sep 2024 15:19:33 +0530 Subject: [PATCH 7/7] center align text in mobile view --- src/Components/CameraFeed/StillWatching.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/CameraFeed/StillWatching.tsx b/src/Components/CameraFeed/StillWatching.tsx index 14255aa0d93..9beb0d3efc1 100644 --- a/src/Components/CameraFeed/StillWatching.tsx +++ b/src/Components/CameraFeed/StillWatching.tsx @@ -78,7 +78,7 @@ export default function StillWatching(props: { children: React.ReactNode }) { /> {state === "timed-out" ? (
- + {t("stream_stopped_due_to_inativity")} reset(true)}>