| 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) {