diff --git a/app/actions/ModActionPanel/QuickAction.tsx b/app/actions/ModActionPanel/QuickAction.tsx index d4e0cea..0ab448f 100644 --- a/app/actions/ModActionPanel/QuickAction.tsx +++ b/app/actions/ModActionPanel/QuickAction.tsx @@ -12,6 +12,7 @@ import { import { isSelfLabel } from '@/common/labels/util' import { FullScreenActionPanel } from '@/common/FullScreenActionPanel' import { PreviewCard } from '@/common/PreviewCard' +import { VideoTimestampProvider } from '@/common/video/TimestampContext' import { createBreakpoint } from 'react-use' import { ArrowLeftIcon, @@ -218,7 +219,7 @@ function Form( } return ( - <> + {/* The inline styling is not ideal but there's no easy way to set calc() values in tailwind */} {/* We are basically telling the browser to leave 180px at the bottom of the container to make room for navigation arrows and use the remaining vertical space for the main content where scrolling will be allowed if content overflows */} {/* @ts-ignore */} @@ -865,6 +866,6 @@ function Form( )} - + ) } diff --git a/app/reports/[id]/page-content.tsx b/app/reports/[id]/page-content.tsx index dacb468..8321fa4 100644 --- a/app/reports/[id]/page-content.tsx +++ b/app/reports/[id]/page-content.tsx @@ -86,6 +86,11 @@ import { useAssignmentPolling } from 'components/reports/useAssignmentPolling' import { ModToolContextPanel } from 'components/reports/ModToolContextPanel' import { ReportBatchLink } from 'components/reports/ReportBatchLink' import { useReportCreationEvent } from 'components/reports/useReportCreationEvent' +import { + getVideoTimestampSeconds, + ReportedVideoTimestamp, +} from 'components/reports/ReportedVideoTimestamp' +import { VideoTimestampProvider } from '@/common/video/TimestampContext' import { getHandleFromSubjectView } from 'components/reports/utils' import { SubjectTagList } from 'components/tags/SubjectTagList' import { WorkspacePanel } from 'components/workspace/Panel' @@ -220,6 +225,10 @@ function ReportInfoPanel({ )} + + {/* Batch actions link, shown for any report whose creating event carries a modTool batchId (not just registered intake tools). */} @@ -684,8 +693,9 @@ function ReportDetailLayout(props: { report.subject.repo?.did || profile?.did || repo?.did || record?.repo.did return ( -
-
+ +
+
{/* Left col */}
{/* Subject preview */} @@ -1129,7 +1139,8 @@ function ReportDetailLayout(props: {
+
-
+ ) } diff --git a/components/common/video/TimestampContext.tsx b/components/common/video/TimestampContext.tsx new file mode 100644 index 0000000..abd6501 --- /dev/null +++ b/components/common/video/TimestampContext.tsx @@ -0,0 +1,59 @@ +'use client' + +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useRef, +} from 'react' +import { toast } from 'react-toastify' + +type VideoTimestampContextValue = { + registerVideo: (video: HTMLVideoElement) => () => void + seekTo: (seconds: number) => void +} + +const VideoTimestampContext = createContext( + null, +) + +export function VideoTimestampProvider({ children }: { children: ReactNode }) { + const videos = useRef(new Set()) + + const registerVideo = useCallback((video: HTMLVideoElement) => { + videos.current.add(video) + return () => videos.current.delete(video) + }, []) + + const seekTo = useCallback((seconds: number) => { + const video = videos.current.values().next().value + if (!video) { + toast.error('No video found') + return + } + + video.currentTime = seconds + if (video.paused) { + void video.play().catch(() => { + toast.error('Unable to play video') + }) + } + }, []) + + const value = useMemo( + () => ({ registerVideo, seekTo }), + [registerVideo, seekTo], + ) + + return ( + + {children} + + ) +} + +export function useVideoTimestamp() { + return useContext(VideoTimestampContext) +} diff --git a/components/common/video/player.tsx b/components/common/video/player.tsx index a45dcb7..e52d57f 100644 --- a/components/common/video/player.tsx +++ b/components/common/video/player.tsx @@ -4,6 +4,7 @@ import Hls from 'hls.js/light' import { useEffect, useId, useRef, useState } from 'react' import { GraphicMediaFilterPreference } from '@/config/useLocalPreferences' import { classNames } from '@/lib/util' +import { useVideoTimestamp } from './TimestampContext' export default function VideoPlayer({ source, @@ -23,6 +24,12 @@ export default function VideoPlayer({ const [isHovered, setIsHovered] = useState(false) const figId = useId() const ref = useRef(null) + const videoTimestamp = useVideoTimestamp() + + useEffect(() => { + if (!ref.current || !videoTimestamp) return + return videoTimestamp.registerVideo(ref.current) + }, [videoTimestamp]) useEffect(() => { if (ref.current && Hls.isSupported()) { diff --git a/components/mod-event/EventItem.tsx b/components/mod-event/EventItem.tsx index 66a2583..9105939 100644 --- a/components/mod-event/EventItem.tsx +++ b/components/mod-event/EventItem.tsx @@ -31,6 +31,10 @@ import { CopyButton } from '@/common/CopyButton' import { useState, type JSX } from 'react' import { UserAgent } from '@/common/UserAgent' +import { + getVideoTimestampSeconds, + ReportedVideoTimestamp, +} from '@/reports/ReportedVideoTimestamp' const LinkToAuthor = ({ creatorHandle, @@ -386,6 +390,9 @@ const Report = ({ {modEvent.event.comment && ( )} + {ChatBskyConvoDefs.isMessageRef(modEvent.subject) && ( diff --git a/components/reports/ReportedVideoTimestamp.tsx b/components/reports/ReportedVideoTimestamp.tsx new file mode 100644 index 0000000..e7e114e --- /dev/null +++ b/components/reports/ReportedVideoTimestamp.tsx @@ -0,0 +1,43 @@ +import { ClockIcon } from '@heroicons/react/24/outline' +import { ToolsOzoneModerationDefs } from '@atproto/api' + +import { useVideoTimestamp } from '@/common/video/TimestampContext' +import { MOD_TOOL_REGISTRY } from '@/lib/constants' +import { + getModToolConfig, + resolveVideoTimestampSeconds, +} from './modToolRegistry' + +export function getVideoTimestampSeconds( + modTool?: ToolsOzoneModerationDefs.ModTool, +) { + const config = getModToolConfig(MOD_TOOL_REGISTRY, modTool?.name) + return resolveVideoTimestampSeconds( + modTool?.meta as Record | undefined, + config, + ) +} + +function formatTimestamp(seconds: number) { + const wholeSeconds = Math.floor(seconds) + const minutes = Math.floor(wholeSeconds / 60) + const remainder = wholeSeconds % 60 + return `${minutes}:${remainder.toString().padStart(2, '0')}s` +} + +export function ReportedVideoTimestamp({ seconds }: { seconds?: number }) { + const videoTimestamp = useVideoTimestamp() + if (seconds === undefined) return null + + return ( + + ) +} diff --git a/components/reports/modToolRegistry.ts b/components/reports/modToolRegistry.ts index 3e29ee2..72f35dd 100644 --- a/components/reports/modToolRegistry.ts +++ b/components/reports/modToolRegistry.ts @@ -32,6 +32,9 @@ export type ModToolConfig = { // these (non-empty) fields are shown as a table and the full meta is offered // behind a "raw" toggle. When absent, all meta entries are auto-rendered. fields?: ModToolFieldConfig[] + // Dot-path in modTool.meta containing the reported video's timestamp in + // seconds. Defaults to "videoTimestampSeconds" when omitted. + videoTimestampPath?: string } export type ModToolRegistry = Record @@ -103,6 +106,19 @@ export function resolvePath(obj: unknown, path: string): unknown { }, obj) } +export function resolveVideoTimestampSeconds( + meta: Record | undefined, + config?: ModToolConfig, +): number | undefined { + const value = resolvePath( + meta, + config?.videoTimestampPath ?? 'videoTimestampSeconds', + ) + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : undefined +} + // Build an admin deep link from a template + meta. Returns undefined unless a // template is provided and every {placeholder} resolves to a non-empty value, // so we never render a half-substituted (broken) link.