From b41b51f7b1d97894f8206891930ef1ecc791978b Mon Sep 17 00:00:00 2001 From: Sophia Shoemaker <1317004+mrscobbler@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:19:38 -0700 Subject: [PATCH] feat(content-sidebar): timeline markers for timestamped comments and annotations Activity sidebar derives video timeline markers from filteredItems (comments with timestamp markup, annotations with frame target) and pushes them to the box-content-preview viewer via a window CustomEvent. Marker clicks bubble back through AnnotatorContext, where the existing handleAnnotationSelect flow renders the overlay and seeks the video. handleAnnotationSelect now seeks frame annotations on the current file version first so the playhead moves before sidebar scroll and overlay render. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/withAnnotations.test.tsx | 1 + .../common/annotator-context/types.js.flow | 13 ++++ .../common/annotator-context/types.ts | 22 ++++++ .../annotator-context/withAnnotations.js.flow | 6 +- .../annotator-context/withAnnotations.tsx | 73 ++++++++++++++++++- .../withAnnotatorContext.js.flow | 4 +- .../withAnnotatorContext.tsx | 12 ++- .../content-sidebar/ActivitySidebar.js | 67 ++++++++++++++++- .../activity-feed-v2/ActivityFeedV2.tsx | 27 +++++++ 9 files changed, 220 insertions(+), 5 deletions(-) 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) {