diff --git a/src/elements/content-sidebar/ActivitySidebar.js b/src/elements/content-sidebar/ActivitySidebar.js index 7d03e9e1c4..f6f8ac6216 100644 --- a/src/elements/content-sidebar/ActivitySidebar.js +++ b/src/elements/content-sidebar/ActivitySidebar.js @@ -116,6 +116,7 @@ type ExternalProps = { type PropsWithoutContext = { elementId: string, file: BoxItem, + getViewer?: Function, hasSidebarInitialized?: boolean, isDisabled: boolean, onAnnotationSelect: Function, @@ -1425,6 +1426,7 @@ class ActivitySidebar extends React.PureComponent { createTask={this.createTask} currentUser={currentUser} feedItems={this.getFilteredFeedItems()} + getViewer={this.props.getViewer} file={file} getApproverWithQuery={this.getApprover} getAvatarUrl={this.getAvatarUrl} diff --git a/src/elements/content-sidebar/SidebarPanels.js b/src/elements/content-sidebar/SidebarPanels.js index 1852192a46..076ceb4373 100644 --- a/src/elements/content-sidebar/SidebarPanels.js +++ b/src/elements/content-sidebar/SidebarPanels.js @@ -435,6 +435,7 @@ class SidebarPanels extends React.Component { currentUser={currentUser} currentUserError={currentUserError} file={file} + getViewer={getViewer} hasSidebarInitialized={isInitialized} onAnnotationSelect={onAnnotationSelect} onVersionChange={onVersionChange} diff --git a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx index ea7488cd49..9c0afc2ee1 100644 --- a/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/ActivityFeedV2.tsx @@ -44,6 +44,7 @@ const ActivityFeedV2 = ({ getAvatarUrl, getMentionAsync, getTaskCollaborators, + getViewer, hasTasks = true, isDisabled = false, isTimestampedCommentsEnabled = false, @@ -72,6 +73,8 @@ const ActivityFeedV2 = ({ const intl = useIntl(); const scrollHandle = useActivityFeedScroll(); const currentUserId = currentUser?.id; + const scrollHandleRef = React.useRef(null); + scrollHandleRef.current = scrollHandle; const headerTitle = intl.formatMessage(commonMessages.sidebarActivityTitle); const scrolledEntryIdRef = React.useRef(null); @@ -307,6 +310,59 @@ const ActivityFeedV2 = ({ ? { formattedTimestamp, isPressed: isTimestampPressed, onPressedChange } : undefined; + React.useEffect(() => { + if (!getViewer || !isVideo) return undefined; + const viewer = getViewer(); + if (!viewer) return undefined; + + const markers: Array<{ + avatarUrl?: string; + colorIndex?: number; + id: string; + initial?: string; + time: number; + type: 'annotation' | 'comment'; + }> = []; + for (const item of filteredItems) { + if (item.type === 'comment' && item.annotationTimestampMs != null) { + const author = item.messages[0]?.author; + markers.push({ + avatarUrl: author?.avatarUrl ?? undefined, + colorIndex: author?.id ?? 0, + id: item.id, + initial: author?.name?.[0] ?? undefined, + time: item.annotationTimestampMs / 1000, + type: 'comment', + }); + } else if (item.type === 'annotation') { + const loc = item.annotation?.target?.location; + if (loc?.type === 'frame' && loc.value != null) { + const author = item.messages[0]?.author; + markers.push({ + avatarUrl: author?.avatarUrl ?? undefined, + colorIndex: author?.id ?? 0, + id: item.id, + initial: author?.name?.[0] ?? undefined, + time: loc.value / 1000, + type: 'annotation', + }); + } + } + } + viewer.emit('comment_markers', markers); + + const handleMarkerSelect = ({ id }: { id: string }) => { + requestAnimationFrame(() => { + scrollHandleRef.current?.scrollTo(id); + }); + }; + viewer.addListener('comment_marker_select', handleMarkerSelect); + return () => { + viewer.removeListener('comment_marker_select', handleMarkerSelect); + viewer.emit('comment_markers', []); + }; + }, [filteredItems, getViewer, isVideo]); + const handleCommentPost = React.useCallback( async (content: unknown) => { if (!onCommentCreate) return; diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx b/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx index 9b77f8e5fd..7ad9e811a9 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/ActivityFeedV2.test.tsx @@ -1046,4 +1046,114 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => { expect(mockScrollTo).toHaveBeenLastCalledWith('new-comment'); }); }); + + describe('comment markers', () => { + const mockViewer = { + addListener: jest.fn(), + emit: jest.fn(), + removeListener: jest.fn(), + }; + const mockGetViewer = jest.fn(() => mockViewer); + + const timestampedComment = { + ...mockComment, + id: 'ts-comment-1', + tagged_message: '#[timestamp:5000,versionId:123] Hello', + }; + + const frameAnnotation = { + ...mockAnnotation, + id: 'frame-ann-1', + target: { location: { type: 'frame', value: 10000 }, type: 'region' }, + }; + + const renderComponentWithMarkers = (props: Partial = {}) => + render( + , + ); + + beforeEach(() => { + mockViewer.addListener.mockClear(); + mockViewer.emit.mockClear(); + mockViewer.removeListener.mockClear(); + mockGetViewer.mockClear(); + }); + + test('should not emit comment_markers when getViewer is not provided', () => { + renderComponentWithMarkers({ getViewer: undefined }); + expect(mockViewer.emit).not.toHaveBeenCalled(); + }); + + test('should not emit comment_markers when file is not a video', () => { + renderComponentWithMarkers({ file: { extension: 'pdf', file_version: { id: '1' } } }); + expect(mockViewer.emit).not.toHaveBeenCalledWith('comment_markers', expect.anything()); + }); + + test('should not emit comment_markers when getViewer returns null', () => { + renderComponentWithMarkers({ getViewer: jest.fn(() => null) }); + expect(mockViewer.emit).not.toHaveBeenCalled(); + }); + + test('should emit comment_markers with timestamped comment data', () => { + renderComponentWithMarkers(); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', [ + expect.objectContaining({ + id: 'ts-comment-1', + time: 5, + type: 'comment', + }), + ]); + }); + + test('should emit comment_markers with frame annotation data', () => { + renderComponentWithMarkers({ feedItems: [frameAnnotation] as ActivityFeedV2Props['feedItems'] }); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', [ + expect.objectContaining({ + id: 'frame-ann-1', + time: 10, + type: 'annotation', + }), + ]); + }); + + test('should include author avatar and initial in markers', () => { + renderComponentWithMarkers(); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', [ + expect.objectContaining({ + initial: 'C', + colorIndex: 2, + }), + ]); + }); + + test('should register comment_marker_select listener on viewer', () => { + renderComponentWithMarkers(); + expect(mockViewer.addListener).toHaveBeenCalledWith('comment_marker_select', expect.any(Function)); + }); + + test('should remove comment_marker_select listener on unmount', () => { + const { unmount } = renderComponentWithMarkers(); + mockViewer.emit.mockClear(); + unmount(); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', []); + expect(mockViewer.removeListener).toHaveBeenCalledWith('comment_marker_select', expect.any(Function)); + }); + + test('should not include non-timestamped comments in markers', () => { + renderComponentWithMarkers({ feedItems: [mockComment] as ActivityFeedV2Props['feedItems'] }); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', []); + }); + + test('should not include page-based annotations in markers', () => { + renderComponentWithMarkers({ feedItems: [mockAnnotation] as ActivityFeedV2Props['feedItems'] }); + expect(mockViewer.emit).toHaveBeenCalledWith('comment_markers', []); + }); + }); }); diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index e0d0984af6..bba219cd8b 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -83,6 +83,12 @@ export type ActivityFeedV2File = { file_version?: { id: string }; }; +export type ViewerHandle = { + addListener: (event: string, handler: (payload: unknown) => void) => void; + emit: (event: string, payload: unknown) => void; + removeListener: (event: string, handler: (payload: unknown) => void) => void; +}; + export type ActivityFeedV2Props = { activeFeedEntryId?: string; approverSelectorContacts?: Array>; @@ -94,6 +100,7 @@ export type ActivityFeedV2Props = { getAvatarUrl?: GetAvatarUrl; getMentionAsync?: (searchStr: string) => Promise>>; getTaskCollaborators?: (task: TaskNew) => Promise; + getViewer?: () => ViewerHandle | null; hasTasks?: boolean; isDisabled?: boolean; isTimestampedCommentsEnabled?: boolean;