diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index d663f627f..02b3eee1c 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -114,6 +114,33 @@ export default function Events() { false, ); + // Wrapper to update URL with review ID for deep linking when opening a recording. + // This does a single atomic navigation to avoid creating multiple history entries + // which would cause issues with the back button. + const onOpenRecording = useCallback( + (recordingInfo: RecordingStartingPoint) => { + const updated = new URLSearchParams(searchParams); + if (recordingInfo.reviewId) { + updated.set("id", recordingInfo.reviewId); + } + const search = updated.toString(); + + // Single navigation that updates both URL params and location state + navigate( + { + pathname: location.pathname, + search: search ? `?${search}` : "", + hash: location.hash, + }, + { + replace: true, + state: { ...location.state, recording: recordingInfo }, + }, + ); + }, + [searchParams, navigate, location.pathname, location.hash, location.state], + ); + const [notificationTab, setNotificationTab] = useState("timeline"); @@ -125,6 +152,13 @@ export default function Events() { }); useSearchEffect("id", (reviewId: string) => { + // If recording is already set (e.g., from clicking a review), don't re-fetch. + // Return false to keep the id param in the URL for deep linking. + if (recording) { + return false; + } + + // Fresh deep link - fetch review data and open recording axios .get(`review/${reviewId}`) .then((resp) => { @@ -142,6 +176,7 @@ export default function Events() { startTime, severity: resp.data.severity, timelineType: notificationTab, + reviewId, }, true, ); @@ -149,7 +184,8 @@ export default function Events() { }) .catch(() => {}); - return true; + // Return false to keep the id param in the URL for deep linking + return false; }); const [startTime, setStartTime] = useState(); @@ -560,7 +596,7 @@ export default function Events() { setSeverity={setSeverity} markItemAsReviewed={markItemAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed} - onOpenRecording={setRecording} + onOpenRecording={onOpenRecording} pullLatestData={reloadData} updateFilter={onUpdateFilter} /> diff --git a/web/src/types/record.ts b/web/src/types/record.ts index 25820c3d9..b278896f6 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -39,6 +39,8 @@ export type RecordingStartingPoint = { startTime: number; severity: ReviewSeverity; timelineType?: TimelineType; + // Optional review ID for deep linking to specific review segments + reviewId?: string; }; export type RecordingPlayerError = "stalled" | "startup"; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index c0b0836bf..11097b4d4 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -168,6 +168,7 @@ export default function EventView({ startTime: effectiveStartTime - REVIEW_PADDING, severity: review.severity, timelineType: detail ? "detail" : undefined, + reviewId: review.id, }); review.has_been_reviewed = true; diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 5b4d5328c..86e5777ea 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -35,7 +35,7 @@ import { } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import useSWR from "swr"; import { TimeRange, TimelineType } from "@/types/timeline"; @@ -97,8 +97,26 @@ export function RecordingView({ const { t } = useTranslation(["views/events"]); const { data: config } = useSWR("config"); const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); const contentRef = useRef(null); + // Navigate back while clearing the review id param to prevent + // useSearchEffect from re-opening the recording + const handleBack = useCallback(() => { + const updated = new URLSearchParams(searchParams); + updated.delete("id"); + const search = updated.toString(); + navigate( + { + pathname: location.pathname, + search: search ? `?${search}` : "", + hash: location.hash, + }, + { replace: true }, + ); + }, [searchParams, navigate, location.pathname, location.hash]); + // recordings summary const timezone = useTimezone(config); @@ -541,7 +559,7 @@ export function RecordingView({ className="flex items-center gap-2.5 rounded-lg" aria-label={t("label.back", { ns: "common" })} size="sm" - onClick={() => navigate(-1)} + onClick={handleBack} > {isDesktop && (