diff --git a/src/elements/common/annotator-context/__tests__/withAnnotations.test.tsx b/src/elements/common/annotator-context/__tests__/withAnnotations.test.tsx index 0f4d7bc896..5874149160 100644 --- a/src/elements/common/annotator-context/__tests__/withAnnotations.test.tsx +++ b/src/elements/common/annotator-context/__tests__/withAnnotations.test.tsx @@ -20,6 +20,7 @@ describe('elements/common/annotator-context/withAnnotations', () => { onAnnotator: jest.fn(), onError: jest.fn(), onPreviewDestroy: jest.fn(), + onViewer: jest.fn(), }; const MockComponent = (props: ComponentProps) =>
; const WrappedComponent = withAnnotations(MockComponent); diff --git a/src/elements/common/annotator-context/types.js.flow b/src/elements/common/annotator-context/types.js.flow index 5fe6c988c6..172a767acb 100644 --- a/src/elements/common/annotator-context/types.js.flow +++ b/src/elements/common/annotator-context/types.js.flow @@ -34,6 +34,17 @@ export interface Annotator { listener: (...args: any[]) => void ) => void; } +export interface TimelineMarker { + id: string; + timestampMs: number; + type: "comment" | "annotation"; +} +export interface TimelineMarkerClickPayload { + id: string; + timestampMs?: number; + type?: string; +} +export type TimelineMarkerClickHandler = (payload: TimelineMarkerClickPayload) => void; export interface AnnotatorState { activeAnnotationFileVersionId?: string | null; activeAnnotationId?: string | null; @@ -52,6 +63,7 @@ export interface AnnotatorState { } export type GetMatchPath = (location?: Location) => match | null; export interface AnnotatorContext { + addTimelineMarkerClickListener?: (handler: TimelineMarkerClickHandler) => () => void; emitActiveAnnotationChangeEvent?: (id: string) => void; emitAnnotationRemoveEvent?: (id: string, isStartEvent?: boolean) => void; emitAnnotationReplyCreateEvent?: ( @@ -76,6 +88,7 @@ export interface AnnotatorContext { ) => void; getAnnotationsMatchPath?: GetMatchPath; getAnnotationsPath?: (fileVersionId?: string, annotationId?: string) => string; + setTimelineMarkers?: (markers: TimelineMarker[]) => void; state: AnnotatorState; } declare export var Status: {| diff --git a/src/elements/common/annotator-context/types.ts b/src/elements/common/annotator-context/types.ts index ecfa67887a..d537c4c84c 100644 --- a/src/elements/common/annotator-context/types.ts +++ b/src/elements/common/annotator-context/types.ts @@ -28,6 +28,20 @@ export interface Annotator { } /* eslint-enable @typescript-eslint/no-explicit-any */ +export interface TimelineMarker { + id: string; + timestampMs: number; + type: 'comment' | 'annotation'; +} + +export interface TimelineMarkerClickPayload { + id: string; + timestampMs?: number; + type?: string; +} + +export type TimelineMarkerClickHandler = (payload: TimelineMarkerClickPayload) => void; + export interface AnnotatorState { activeAnnotationFileVersionId?: string | null; activeAnnotationId?: string | null; @@ -41,7 +55,14 @@ export interface AnnotatorState { export type GetMatchPath = (location?: Location) => match | null; +// Bridges the imperative box-annotations Annotator into the React tree below. +// Also exposes timeline-marker hooks (setTimelineMarkers / +// addTimelineMarkerClickListener) which talk to the box-content-preview viewer +// through window-level CustomEvents — no direct viewer reference is held by +// the host. Both surfaces share this single provider since they share +// ContentPreview's lifecycle. export interface AnnotatorContext { + addTimelineMarkerClickListener?: (handler: TimelineMarkerClickHandler) => () => void; emitActiveAnnotationChangeEvent?: (id: string) => void; emitAnnotationRemoveEvent?: (id: string, isStartEvent?: boolean) => void; emitAnnotationReplyCreateEvent?: ( @@ -55,6 +76,7 @@ export interface AnnotatorContext { emitAnnotationUpdateEvent?: (annotation: Object, isStartEvent?: boolean) => void; getAnnotationsMatchPath?: GetMatchPath; getAnnotationsPath?: (fileVersionId?: string, annotationId?: string) => string; + setTimelineMarkers?: (markers: TimelineMarker[]) => void; state: AnnotatorState; } diff --git a/src/elements/common/annotator-context/withAnnotations.js.flow b/src/elements/common/annotator-context/withAnnotations.js.flow index 37abf116b5..4294b5e69e 100644 --- a/src/elements/common/annotator-context/withAnnotations.js.flow +++ b/src/elements/common/annotator-context/withAnnotations.js.flow @@ -16,7 +16,9 @@ import { AnnotatorState, GetMatchPath, MatchParams, - Status + Status, + TimelineMarker, + TimelineMarkerClickHandler } from "./types"; import { SidebarNavigation } from '../types/SidebarNavigation'; import { type FeatureConfig } from '../feature-checking'; @@ -29,6 +31,7 @@ export type ActiveChangeEvent = { }; export type ActiveChangeEventHandler = (event: ActiveChangeEvent) => void; export type ComponentWithAnnotations = { + addTimelineMarkerClickListener: (handler: TimelineMarkerClickHandler) => () => void, emitActiveAnnotationChangeEvent: (id: string | null) => void, emitAnnotationRemoveEvent: (id: string, isStartEvent?: boolean) => void, emitAnnotationReplyCreateEvent: ( @@ -71,6 +74,7 @@ export type ComponentWithAnnotations = { handleAnnotationUpdate: (eventData: AnnotationActionEvent) => void, handleAnnotator: (annotator: Annotator) => void, handlePreviewDestroy: (shouldReset?: boolean) => void, + setTimelineMarkers: (markers: TimelineMarker[]) => void, ... }; export type WithAnnotationsProps = { diff --git a/src/elements/common/annotator-context/withAnnotations.tsx b/src/elements/common/annotator-context/withAnnotations.tsx index c12d793bce..7034adee45 100644 --- a/src/elements/common/annotator-context/withAnnotations.tsx +++ b/src/elements/common/annotator-context/withAnnotations.tsx @@ -4,7 +4,17 @@ import { generatePath, match as matchType, matchPath } from 'react-router-dom'; import { Location } from 'history'; import AnnotatorContext from './AnnotatorContext'; import { isFeatureEnabled, type FeatureConfig } from '../feature-checking'; -import { Action, Annotator, AnnotationActionEvent, AnnotatorState, GetMatchPath, MatchParams, Status } from './types'; +import { + Action, + Annotator, + AnnotationActionEvent, + AnnotatorState, + GetMatchPath, + MatchParams, + Status, + TimelineMarker, + TimelineMarkerClickHandler, +} from './types'; import { FeedEntryType, SidebarNavigation } from '../types/SidebarNavigation'; export type ActiveChangeEvent = { @@ -15,6 +25,7 @@ export type ActiveChangeEvent = { export type ActiveChangeEventHandler = (event: ActiveChangeEvent) => void; export type ComponentWithAnnotations = { + addTimelineMarkerClickListener: (handler: TimelineMarkerClickHandler) => () => void; emitActiveAnnotationChangeEvent: (id: string | null) => void; emitAnnotationRemoveEvent: (id: string, isStartEvent?: boolean) => void; emitAnnotationReplyCreateEvent: ( @@ -40,6 +51,7 @@ export type ComponentWithAnnotations = { handleAnnotationUpdate: (eventData: AnnotationActionEvent) => void; handleAnnotator: (annotator: Annotator) => void; handlePreviewDestroy: (shouldReset?: boolean) => void; + setTimelineMarkers: (markers: TimelineMarker[]) => void; }; export type WithAnnotationsProps = { @@ -73,6 +85,12 @@ export default function withAnnotations

( annotator: Annotator | null = null; + // Cached so we can replay markers onto a viewer that arrives after the + // feed has rendered (e.g. file navigation while sidebar stays mounted). + // The SDK dispatches `bp:timeline_markers_ready` when its listener is in + // place; we re-emit the cached list in response. + lastTimelineMarkers: TimelineMarker[] | null = null; + constructor(props: P & WithAnnotationsProps) { super(props); @@ -101,6 +119,27 @@ export default function withAnnotations

( this.state = { ...defaultState, activeAnnotationId }; } + componentDidMount(): void { + if (typeof window !== 'undefined') { + window.addEventListener('bp:timeline_markers_ready', this.handleTimelineMarkersReady); + } + } + + componentWillUnmount(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('bp:timeline_markers_ready', this.handleTimelineMarkersReady); + } + } + + handleTimelineMarkersReady = (): void => { + // The viewer just attached its listener; replay whatever the host + // last pushed so the scrubber is correct without the host having to + // re-derive on the next feed mutation. + if (this.lastTimelineMarkers) { + this.setTimelineMarkers(this.lastTimelineMarkers); + } + }; + emitActiveAnnotationChangeEvent = (id: string | null) => { const { annotator } = this; @@ -338,6 +377,36 @@ export default function withAnnotations

( } this.annotator = null; + this.lastTimelineMarkers = null; + }; + + // Pushes markers into the SDK via a window-level CustomEvent. The viewer + // listens on `bp:timeline_markers_update`; we never hold a reference to + // the viewer instance. Cached so the ready handler can replay onto a + // viewer that mounts after the first push. + setTimelineMarkers = (markers: TimelineMarker[]): void => { + this.lastTimelineMarkers = markers; + if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') { + window.dispatchEvent(new window.CustomEvent('bp:timeline_markers_update', { detail: markers })); + } + }; + + // Subscribes the host to viewer-emitted marker clicks. The SDK dispatches + // `bp:timeline_marker_click` on the window; we wrap the handler to unbox + // the CustomEvent's detail so consumers see a plain payload object. + addTimelineMarkerClickListener = (handler: TimelineMarkerClickHandler): (() => void) => { + const noopUnsubscribe = (): void => undefined; + if (typeof window === 'undefined') { + return noopUnsubscribe; + } + const wrapped = (event: Event): void => { + const { detail } = event as CustomEvent; + if (detail && typeof detail.id === 'string') { + handler(detail); + } + }; + window.addEventListener('bp:timeline_marker_click', wrapped); + return () => window.removeEventListener('bp:timeline_marker_click', wrapped); }; render(): JSX.Element { @@ -352,12 +421,14 @@ export default function withAnnotations

( return ( () => void; annotatorState?: AnnotatorState; emitActiveAnnotationChangeEvent?: (id: string) => void; emitAnnotationRemoveEvent?: (id: string, isStartEvent?: boolean) => void; @@ -37,6 +38,7 @@ export interface WithAnnotatorContextProps { fileVersionId?: string, annotationId?: string ) => string; + setTimelineMarkers?: (markers: TimelineMarker[]) => void; } declare export default function withAnnotatorContext( WrappedComponent: React.ComponentType

diff --git a/src/elements/common/annotator-context/withAnnotatorContext.tsx b/src/elements/common/annotator-context/withAnnotatorContext.tsx index b3b7b1e70a..ec8b39c5d1 100644 --- a/src/elements/common/annotator-context/withAnnotatorContext.tsx +++ b/src/elements/common/annotator-context/withAnnotatorContext.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import AnnotatorContext from './AnnotatorContext'; import { isFeatureEnabled, type FeatureConfig } from '../feature-checking'; -import { AnnotatorState, GetMatchPath } from './types'; +import { AnnotatorState, GetMatchPath, TimelineMarker, TimelineMarkerClickHandler } from './types'; export interface WithAnnotatorContextProps { + addTimelineMarkerClickListener?: (handler: TimelineMarkerClickHandler) => () => void; annotatorState?: AnnotatorState; emitActiveAnnotationChangeEvent?: (id: string) => void; emitAnnotationRemoveEvent?: (id: string, isStartEvent?: boolean) => void; @@ -18,6 +19,7 @@ export interface WithAnnotatorContextProps { emitAnnotationUpdateEvent?: (annotation: Object, isStartEvent?: boolean) => void; getAnnotationsMatchPath?: GetMatchPath; getAnnotationsPath?: (fileVersionId?: string, annotationId?: string) => string; + setTimelineMarkers?: (markers: TimelineMarker[]) => void; } export default function withAnnotatorContext

(WrappedComponent: React.ComponentType

) { @@ -27,17 +29,20 @@ export default function withAnnotatorContext

(WrappedComponent: Rea return ( {({ + addTimelineMarkerClickListener, emitActiveAnnotationChangeEvent, emitAnnotationRemoveEvent, emitAnnotationReplyCreateEvent, emitAnnotationReplyDeleteEvent, emitAnnotationReplyUpdateEvent, emitAnnotationUpdateEvent, + setTimelineMarkers, state, }) => ( (WrappedComponent: Rea emitAnnotationReplyDeleteEvent={emitAnnotationReplyDeleteEvent} emitAnnotationReplyUpdateEvent={emitAnnotationReplyUpdateEvent} emitAnnotationUpdateEvent={emitAnnotationUpdateEvent} + setTimelineMarkers={setTimelineMarkers} /> )} @@ -53,6 +59,7 @@ export default function withAnnotatorContext

(WrappedComponent: Rea return ( {({ + addTimelineMarkerClickListener, emitActiveAnnotationChangeEvent, emitAnnotationRemoveEvent, emitAnnotationReplyCreateEvent, @@ -61,11 +68,13 @@ export default function withAnnotatorContext

(WrappedComponent: Rea emitAnnotationUpdateEvent, getAnnotationsMatchPath, getAnnotationsPath, + setTimelineMarkers, state, }) => ( (WrappedComponent: Rea emitAnnotationUpdateEvent={emitAnnotationUpdateEvent} getAnnotationsMatchPath={getAnnotationsMatchPath} getAnnotationsPath={getAnnotationsPath} + setTimelineMarkers={setTimelineMarkers} /> )} diff --git a/src/elements/content-sidebar/ActivitySidebar.js b/src/elements/content-sidebar/ActivitySidebar.js index 7d03e9e1c4..a66f9f096b 100644 --- a/src/elements/content-sidebar/ActivitySidebar.js +++ b/src/elements/content-sidebar/ActivitySidebar.js @@ -16,6 +16,7 @@ import { generatePath, type ContextRouter } from 'react-router-dom'; import ActivityFeed from './activity-feed'; // $FlowFixMe import ActivityFeedV2 from './activity-feed-v2'; +import { seekVideoToMs } from './activity-feed-v2/useVideoTimestamp'; import AddTaskButton from './AddTaskButton'; import API from '../../api'; import messages from '../common/messages'; @@ -163,6 +164,8 @@ class ActivitySidebar extends React.PureComponent { emitAnnotationReplyDeleteEvent: noop, emitAnnotationReplyUpdateEvent: noop, emitAnnotationUpdateEvent: noop, + addTimelineMarkerClickListener: undefined, + setTimelineMarkers: undefined, getAnnotationsMatchPath: noop, getAnnotationsPath: noop, hasReplies: false, @@ -196,10 +199,13 @@ class ActivitySidebar extends React.PureComponent { } componentDidMount() { - const { shouldFetchSidebarData = true } = this.props; + const { addTimelineMarkerClickListener, shouldFetchSidebarData = true } = this.props; if (shouldFetchSidebarData) { this.fetchFeedItems(true); } + if (addTimelineMarkerClickListener) { + this.timelineMarkerUnsubscribe = addTimelineMarkerClickListener(this.handleTimelineMarkerClick); + } } componentDidUpdate(prevProps: Props) { @@ -226,8 +232,57 @@ class ActivitySidebar extends React.PureComponent { } this.pendingMentionResolve = null; this.pendingMentionReject = null; + if (this.timelineMarkerUnsubscribe) { + this.timelineMarkerUnsubscribe(); + this.timelineMarkerUnsubscribe = null; + } } + timelineMarkerUnsubscribe: ?() => void = null; + + handleTimelineMarkerClick = ({ + id, + timestampMs, + type, + }: { + id: string, + timestampMs?: number, + type?: string, + }): void => { + if (!id) return; + + if (type === 'annotation') { + const { feedItems } = this.state; + const annotationItem = feedItems + ? feedItems.find(item => item.id === id && item.type === FEED_ITEM_TYPE_ANNOTATION) + : null; + if (annotationItem) { + // Reuse the badge-click flow so the annotator's active-annotation + // state, URL push, overlay rendering, and seek all stay consistent. + this.handleAnnotationSelect(annotationItem); + return; + } + } + + // Comment markers carry a timestamp directly; seek immediately so the + // playhead moves before the sidebar scroll catches up. + if (typeof timestampMs === 'number') { + seekVideoToMs(timestampMs); + } + + const { history, internalSidebarNavigationHandler, routerDisabled } = this.props; + + if (routerDisabled && internalSidebarNavigationHandler) { + internalSidebarNavigationHandler({ + sidebar: ViewType.ACTIVITY, + activeFeedEntryId: id, + activeFeedEntryType: FeedEntryType.COMMENTS, + }); + } else { + history.push(this.getActiveCommentPath(id)); + } + }; + handleAnnotationDelete = ({ id, permissions }: { id: string, permissions: AnnotationPermission }) => { const { api, emitAnnotationRemoveEvent, file } = this.props; @@ -1243,6 +1298,16 @@ class ActivitySidebar extends React.PureComponent { selectedFileVersionId = getProp(match, 'params.fileVersionId', currentFileVersionId); } + // Seek the video first when the annotation is on the current version so the + // playhead moves before the sidebar scroll and overlay render. Cross-version + // annotations rely on the existing setState({ startAt }) flow below. + const annotationLocation = annotation?.target?.location; + const isFrameAnnotation = annotationLocation?.type === 'frame'; + const isSameVersion = !annotationFileVersionId || annotationFileVersionId === selectedFileVersionId; + if (isFrameAnnotation && isSameVersion && typeof annotationLocation.value === 'number') { + seekVideoToMs(annotationLocation.value); + } + emitActiveAnnotationChangeEvent(nextActiveAnnotationId); if (annotationFileVersionId && annotationFileVersionId !== selectedFileVersionId) { diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index a2f0adcc8b..ec797b1c13 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -14,6 +14,7 @@ import { ActivityFeed, useActivityFeedScroll } from '@box/activity-feed'; import TaskModal from '../TaskModal'; +import { AnnotatorContext } from '../../common/annotator-context'; import FeedItemRow from './FeedItemRow'; import { serializeEditorContent } from './helpers'; import { transformFeedItem } from './transformers'; @@ -21,6 +22,7 @@ import { useTimeFormat } from './useTimeFormat'; import { useVideoTimestamp } from './useVideoTimestamp'; import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types'; +import type { TimelineMarker } from '../../common/annotator-context'; import type { TaskAssigneeCollection, TaskNew } from '../../../common/types/tasks'; import { FILE_EXTENSIONS } from '../../common/item/constants'; @@ -248,6 +250,31 @@ const ActivityFeedV2 = ({ return filtered; }, [currentUserId, showOnlyMentionsMe, showResolved, transformedItems]); + const timelineMarkers = React.useMemo(() => { + const markers: TimelineMarker[] = []; + filteredItems.forEach(item => { + if (item.type === 'comment' && typeof item.annotationTimestampMs === 'number') { + markers.push({ id: item.id, timestampMs: item.annotationTimestampMs, type: 'comment' }); + return; + } + if (item.type === 'annotation') { + const location = item.annotation?.target?.location; + if (location?.type === 'frame' && typeof location.value === 'number') { + markers.push({ id: item.id, timestampMs: location.value, type: 'annotation' }); + } + } + }); + return markers; + }, [filteredItems]); + + const { setTimelineMarkers } = React.useContext(AnnotatorContext); + + React.useEffect(() => { + if (setTimelineMarkers) { + setTimelineMarkers(timelineMarkers); + } + }, [setTimelineMarkers, timelineMarkers]); + React.useEffect(() => { const alreadyScrolledToThisEntry = scrolledEntryIdRef.current === activeFeedEntryId; if (!activeFeedEntryId || !scrollHandle || alreadyScrolledToThisEntry) {