diff --git a/src/Components/CameraFeed/CameraFeed.tsx b/src/Components/CameraFeed/CameraFeed.tsx index e49a63f7028..59d0011d393 100644 --- a/src/Components/CameraFeed/CameraFeed.tsx +++ b/src/Components/CameraFeed/CameraFeed.tsx @@ -1,18 +1,31 @@ +import * as Notification from "../../Utils/Notifications"; + +import FeedAlert, { FeedAlertState, StreamStatus } from "./FeedAlert"; +import { + GetLockCameraResponse, + GetPresetsResponse, + GetRequestAccessResponse, +} from "./routes"; +import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import { classNames, isIOS } from "../../Utils/utils"; import { useCallback, useEffect, useRef, useState } from "react"; -import { AssetData } from "../Assets/AssetTypes"; import useOperateCamera, { PTZPayload } from "./useOperateCamera"; -import { getStreamUrl } from "./utils"; -import { classNames, isIOS } from "../../Utils/utils"; -import FeedAlert, { FeedAlertState, StreamStatus } from "./FeedAlert"; -import FeedNetworkSignal from "./FeedNetworkSignal"; -import NoFeedAvailable from "./NoFeedAvailable"; + +import { AssetData } from "../Assets/AssetTypes"; +import ButtonV2 from "../Common/components/ButtonV2"; import FeedControls from "./FeedControls"; +import FeedNetworkSignal from "./FeedNetworkSignal"; import FeedWatermark from "./FeedWatermark"; -import useFullscreen from "../../Common/hooks/useFullscreen"; -import useBreakpoints from "../../Common/hooks/useBreakpoints"; -import { GetPresetsResponse } from "./routes"; -import VideoPlayer from "./videoPlayer"; import MonitorAssetPopover from "../Common/MonitorAssetPopover"; +import NoFeedAvailable from "./NoFeedAvailable"; +import { UserBareMinimum } from "../Users/models"; +import VideoPlayer from "./videoPlayer"; +import { getStreamUrl } from "./utils"; +import useAuthUser from "../../Common/hooks/useAuthUser"; +import useBreakpoints from "../../Common/hooks/useBreakpoints"; +import useFullscreen from "../../Common/hooks/useFullscreen"; +import { useMessageListener } from "../../Common/hooks/useMessageListener"; +import { useTranslation } from "react-i18next"; interface Props { children?: React.ReactNode; @@ -31,6 +44,7 @@ interface Props { } export default function CameraFeed(props: Props) { + const { t } = useTranslation(); const playerRef = useRef(null); const playerWrapperRef = useRef(null); const [streamUrl, setStreamUrl] = useState(""); @@ -40,6 +54,60 @@ export default function CameraFeed(props: Props) { const [state, setState] = useState(); const [playedOn, setPlayedOn] = useState(); const [playerStatus, setPlayerStatus] = useState("stop"); + + const [cameraUser, setCameraUser] = useState(); + const user = useAuthUser(); + + const lockCamera = useCallback(async () => { + const { res, data, error } = await props.operate({ type: "lock_camera" }); + + const successData = data as GetLockCameraResponse; + const errorData = error as GetLockCameraResponse["result"]; + + if (res?.status === 200 && successData?.result) { + Notification.Success({ + msg: successData.result.message, + }); + setCameraUser(successData.result.camera_user); + } else if (res?.status === 409 && errorData) { + Notification.Warn({ + msg: errorData.message, + }); + setCameraUser(errorData.camera_user); + } else { + Notification.Error({ + msg: t("camera_locking_error"), + }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const unlockCamera = useCallback(async () => { + await props.operate({ type: "unlock_camera" }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + lockCamera(); + + return () => { + unlockCamera(); + }; + }, [lockCamera, unlockCamera]); + + useMessageListener((data) => { + if (data?.action === "CAMERA_ACCESS_REQUEST") { + Notification.Warn({ + msg: data?.message, + }); + } + + if (data?.action === "CAMERA_AVAILABILITY") { + Notification.Success({ + msg: data?.message, + }); + lockCamera(); + } + }); + // Move camera when selected preset has changed useEffect(() => { async function move(preset: PTZPayload) { @@ -57,7 +125,7 @@ export default function CameraFeed(props: Props) { if (props.preset) { move(props.preset); } - }, [props.preset]); + }, [props.preset]); // eslint-disable-line react-hooks/exhaustive-deps // Get camera presets (only if onCameraPresetsObtained is provided) useEffect(() => { @@ -69,7 +137,7 @@ export default function CameraFeed(props: Props) { } } getPresets(props.onCameraPresetsObtained); - }, [props.operate, props.onCameraPresetsObtained]); + }, [props.operate, props.onCameraPresetsObtained]); // eslint-disable-line react-hooks/exhaustive-deps const initializeStream = useCallback(async () => { if (!playerRef.current) return; @@ -88,12 +156,12 @@ export default function CameraFeed(props: Props) { setState("host_unreachable"); return props.onStreamError?.(); }); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps // Start stream on mount useEffect(() => { initializeStream(); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const resetStream = () => { setState("loading"); @@ -133,14 +201,27 @@ export default function CameraFeed(props: Props) { onReset={resetStream} onMove={async (data) => { setState("moving"); - const { res } = await props.operate({ type: "relative_move", data }); + const { res, error } = await props.operate({ + type: "relative_move", + data, + }); props.onMove?.(); setTimeout(() => { setState((state) => (state === "moving" ? undefined : state)); }, 4000); + if (res?.status === 500) { setState("host_unreachable"); } + + if (res?.status === 409 && error) { + const errorData = error as GetLockCameraResponse["result"]; + + Notification.Warn({ + msg: errorData?.message, + }); + setCameraUser(errorData?.camera_user); + } }} /> ); @@ -177,12 +258,12 @@ export default function CameraFeed(props: Props) { playerStatus !== "playing" ? "pointer-events-none opacity-10" : "opacity-100", - "transition-all duration-200 ease-in-out", + "flex-1 transition-all duration-200 ease-in-out", )} > {props.children} -
+
{props.asset.name} @@ -198,13 +279,79 @@ export default function CameraFeed(props: Props) { )} >
)} + {cameraUser && ( + + + {cameraUser.username[0]} + + + + +
+

+ {[ + cameraUser.first_name, + cameraUser.last_name, + `(${cameraUser.username})`, + ] + .filter(Boolean) + .join(" ")} +

+

+ {cameraUser.user_type} +

+

+ {cameraUser.email} +

+
+
+ + {cameraUser.username !== user.username && ( + +
+

{t("need_camera_access")}

+ { + const { res, data } = await props.operate({ + type: "request_access", + }); + + const successData = + data as GetRequestAccessResponse; + + if (res?.status === 200) { + Notification.Success({ + msg: successData.result.message, + }); + setCameraUser(successData.result.camera_user); + } else { + Notification.Error({ + msg: t("request_camera_access_error"), + }); + } + }} + > + {t("request_access")} + +
+
+ )} +
+
+ )}
diff --git a/src/Components/CameraFeed/NoFeedAvailable.tsx b/src/Components/CameraFeed/NoFeedAvailable.tsx index 1c05296fad1..2625c330cad 100644 --- a/src/Components/CameraFeed/NoFeedAvailable.tsx +++ b/src/Components/CameraFeed/NoFeedAvailable.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import CareIcon, { IconName } from "../../CAREUI/icons/CareIcon"; import { classNames } from "../../Utils/utils"; import { AssetData } from "../Assets/AssetTypes"; @@ -13,6 +14,8 @@ interface Props { } export default function NoFeedAvailable(props: Props) { + const { t } = useTranslation(); + const redactedURL = props.streamUrl // Replace all uuids in the URL with "ID_REDACTED" .replace(/[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}/gi, "***") @@ -40,7 +43,7 @@ export default function NoFeedAvailable(props: Props) { onClick={props.onResetClick} > - Retry + {t("retry")} - Configure + {t("configure")}
diff --git a/src/Components/CameraFeed/PrivacyToggle.tsx b/src/Components/CameraFeed/PrivacyToggle.tsx new file mode 100644 index 00000000000..20cd5a0c1a7 --- /dev/null +++ b/src/Components/CameraFeed/PrivacyToggle.tsx @@ -0,0 +1,103 @@ +import ButtonV2 from "../Common/components/ButtonV2"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import request from "../../Utils/request/request"; +import routes from "../../Redux/api"; +import useQuery from "../../Utils/request/useQuery"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface PrivacyToggleProps { + consultationBedId: string; + initalValue?: boolean; + onChange?: (value: boolean) => void; +} + +export default function PrivacyToggle({ + consultationBedId, + initalValue, + + onChange, +}: PrivacyToggleProps) { + const [isPrivacyEnabled, setIsPrivacyEnabled] = useState( + initalValue ?? false, + ); + + const updatePrivacyChange = (value: boolean) => { + setIsPrivacyEnabled(value); + onChange?.(value); + }; + + useQuery(routes.getConsultationBed, { + pathParams: { externalId: consultationBedId }, + onResponse(res) { + updatePrivacyChange(res.data?.is_privacy_enabled ?? false); + }, + }); + + return ( + + ); +} + +type TogglePrivacyButtonProps = { + value: boolean; + consultationBedId: string; + onChange: (value: boolean) => void; + iconOnly?: boolean; +}; + +export function TogglePrivacyButton({ + value: isPrivacyEnabled, + consultationBedId, + onChange: updatePrivacyChange, + iconOnly = false, +}: TogglePrivacyButtonProps) { + const { t } = useTranslation(); + + return ( + { + const { res, data } = await request( + routes.toggleConsultationBedPrivacy, + { + pathParams: { externalId: consultationBedId }, + body: { + is_privacy_enabled: !isPrivacyEnabled, + }, + }, + ); + + if (res?.ok && data) { + updatePrivacyChange(data.is_privacy_enabled); + } + }} + > + {!iconOnly && ( + + {isPrivacyEnabled ? t("disable_privacy") : t("enable_privacy")} + + )} + + + ); +} diff --git a/src/Components/CameraFeed/routes.ts b/src/Components/CameraFeed/routes.ts index aecbdc655fa..2d4246b55f0 100644 --- a/src/Components/CameraFeed/routes.ts +++ b/src/Components/CameraFeed/routes.ts @@ -1,6 +1,8 @@ -import { Type } from "../../Redux/api"; import { OperationAction, PTZPayload } from "./useOperateCamera"; +import { Type } from "../../Redux/api"; +import { UserBareMinimum } from "../Users/models"; + export type GetStatusResponse = { result: { position: PTZPayload; @@ -23,12 +25,25 @@ export type GetPresetsResponse = { result: Record; }; +export type GetLockCameraResponse = { + result: { + message: string; + camera_user: UserBareMinimum; + }; +}; + +export type GetRequestAccessResponse = GetLockCameraResponse; + export const FeedRoutes = { operateAsset: { path: "/api/v1/asset/{id}/operate_assets/", method: "POST", TRes: Type< - GetStreamTokenResponse | GetStatusResponse | GetPresetsResponse + | GetStreamTokenResponse + | GetStatusResponse + | GetPresetsResponse + | GetLockCameraResponse + | GetRequestAccessResponse >(), TBody: Type<{ action: OperationAction }>(), }, diff --git a/src/Components/CameraFeed/useOperateCamera.ts b/src/Components/CameraFeed/useOperateCamera.ts index bfddbf5b887..588a771e0e0 100644 --- a/src/Components/CameraFeed/useOperateCamera.ts +++ b/src/Components/CameraFeed/useOperateCamera.ts @@ -1,6 +1,6 @@ -import { useState } from "react"; -import request from "../../Utils/request/request"; import { FeedRoutes } from "./routes"; +import request from "../../Utils/request/request"; +import { useState } from "react"; export interface PTZPayload { x: number; @@ -41,6 +41,18 @@ interface ResetFeedOperation { type: "reset"; } +interface LockCameraOperation { + type: "lock_camera"; +} + +interface UnlockCameraOperation { + type: "unlock_camera"; +} + +interface RequestAccessOperation { + type: "request_access"; +} + export type OperationAction = | GetStatusOperation | GetPresetsOperation @@ -48,7 +60,10 @@ export type OperationAction = | AbsoluteMoveOperation | RelativeMoveOperation | GetStreamToken - | ResetFeedOperation; + | ResetFeedOperation + | LockCameraOperation + | UnlockCameraOperation + | RequestAccessOperation; /** * This hook is used to control the PTZ of a camera asset and retrieve other related information. diff --git a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx index 2e999d1956e..7f9010c6127 100644 --- a/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx +++ b/src/Components/Facility/ConsultationDetails/ConsultationFeedTab.tsx @@ -22,6 +22,9 @@ import { Warn } from "../../../Utils/Notifications"; import { useTranslation } from "react-i18next"; import { GetStatusResponse } from "../../CameraFeed/routes"; import StillWatching from "../../CameraFeed/StillWatching"; +import PrivacyToggle, { + TogglePrivacyButton, +} from "../../CameraFeed/PrivacyToggle"; export const ConsultationFeedTab = (props: ConsultationTabProps) => { const { t } = useTranslation(); @@ -29,6 +32,9 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { const facility = useSlug("facility"); const bed = props.consultationData.current_bed?.bed_object; const feedStateSessionKey = `encounterFeedState[${props.consultationId}]`; + const [isPrivacyEnabled, setIsPrivacyEnabled] = useState( + props.consultationData.current_bed?.is_privacy_enabled ?? false, + ); const [asset, setAsset] = useState(); const [preset, setPreset] = useState(); @@ -36,7 +42,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { useState(false); const [isUpdatingPreset, setIsUpdatingPreset] = useState(false); const [hasMoved, setHasMoved] = useState(false); - const divRef = useRef(); + const divRef = useRef(null); const suggestOptimalExperience = useBreakpoints({ default: true, sm: false }); @@ -50,12 +56,12 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { ), }); } - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps const { key, operate } = useOperateCamera(asset?.id ?? "", true); const { data, loading, refetch } = useQuery(routes.listAssetBeds, { - query: { limit: 100, facility, bed: bed?.id, asset: asset?.id }, + query: { limit: 100, facility, bed: bed?.id }, prefetch: !!bed, onResponse: ({ data }) => { if (!data) { @@ -124,7 +130,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { if (divRef.current) { divRef.current.scrollIntoView({ behavior: "smooth" }); } - }, [!!bed, loading, !!asset, divRef.current]); + }, [!!bed, loading, !!asset, divRef.current]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (preset?.id) { @@ -143,16 +149,31 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { } if (!bed || !asset) { - return No bed/asset linked allocated; + return {t("no_bed_or_asset_linked")}; } const cannotSaveToPreset = !hasMoved || !preset?.id; + if (isPrivacyEnabled && props.consultationData.current_bed) { + return ( +
+ + {t("camera_feed_disabled_due_to_privacy")} + + +
+ ); + } + return ( setShowPresetSaveConfirmation(false)} @@ -202,6 +223,13 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { }} >
+ {props.consultationData.current_bed && ( + + )} {presets ? ( <> { shadow={!cannotSaveToPreset} tooltip={ !cannotSaveToPreset - ? "Save current position to selected preset" - : "Change camera position to update preset" + ? t("save_current_position_to_preset") + : t("change_camera_position_and_update_preset") } tooltipClassName="translate-x-3 translate-y-8 text-xs" className="ml-1" @@ -253,7 +281,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => { )} ) : ( - loading presets... + {t("loading_preset") + "..."} )}
diff --git a/src/Components/Facility/models.tsx b/src/Components/Facility/models.tsx index 8103ebb6729..5baa63c289c 100644 --- a/src/Components/Facility/models.tsx +++ b/src/Components/Facility/models.tsx @@ -299,6 +299,7 @@ export interface CurrentBed { modified_date: string; start_date: string; end_date: string; + is_privacy_enabled: boolean; meta: Record; } diff --git a/src/Locale/en.json b/src/Locale/en.json index a25dfc14d84..33755045d0b 100644 --- a/src/Locale/en.json +++ b/src/Locale/en.json @@ -1025,6 +1025,22 @@ "resource_approving_facility" : "Resource approving facility", "consultation_not_filed": "You have not filed any consultation for this patient yet.", "consultation_not_filed_description": "Please file a consultation for this patient to continue.", + "camera_locking_error": "An error occurred while locking the camera", + "need_camera_access": "Need access to move camera?", + "request_camera_access_error": "An error occurred while requesting access", + "request_access": "Request Access", + "enable_privacy": "Enable Privacy", + "disable_privacy": "Disable Privacy", + "privacy_enabled_tooltip": "Privacy is enabled. Click to disable privacy", + "privacy_disabled_tooltip": "Privacy is disabled. Click to enable privacy", + "no_bed_or_asset_linked": "No bed/asset linked allocated", + "camera_feed_disabled_due_to_privacy": "The camera feed is currently disabled due to privacy settings.", + "update_preset": "Update Preset", + "update_preset_confirmation": "Are you sure you want to update this preset to the current location?", + "save_current_position_to_preset": "Save current position to selected preset", + "change_camera_position_and_update_preset": "Change camera position to update preset", + "loading_preset": "Loading Preset", + "retry": "Retry", "width": "Width ({{unit}})", "length": "Length ({{unit}})" } diff --git a/src/Redux/api.tsx b/src/Redux/api.tsx index 4775674084e..e92bfd132a0 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -555,13 +555,20 @@ const routes = { TRes: Type>(), }, getConsultationBed: { - path: "/api/v1/consultationbed/{external_id}/", + path: "/api/v1/consultationbed/{externalId}/", method: "GET", + TRes: Type(), }, updateConsultationBed: { path: "/api/v1/consultationbed/{external_id}/", method: "PUT", }, + toggleConsultationBedPrivacy: { + path: "/api/v1/consultationbed/{externalId}/set_privacy/", + method: "PATCH", + TBody: Type<{ is_privacy_enabled: boolean }>(), + TRes: Type(), + }, // Download Api deleteFacility: {