Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/elements/content-sidebar/ActivitySidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ type ExternalProps = {
type PropsWithoutContext = {
elementId: string,
file: BoxItem,
getViewer?: Function,
hasSidebarInitialized?: boolean,
isDisabled: boolean,
onAnnotationSelect: Function,
Expand Down Expand Up @@ -1425,6 +1426,7 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
createTask={this.createTask}
currentUser={currentUser}
feedItems={this.getFilteredFeedItems()}
getViewer={this.props.getViewer}
file={file}
getApproverWithQuery={this.getApprover}
getAvatarUrl={this.getAvatarUrl}
Expand Down
1 change: 1 addition & 0 deletions src/elements/content-sidebar/SidebarPanels.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ class SidebarPanels extends React.Component<Props, State> {
currentUser={currentUser}
currentUserError={currentUserError}
file={file}
getViewer={getViewer}
hasSidebarInitialized={isInitialized}
onAnnotationSelect={onAnnotationSelect}
onVersionChange={onVersionChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const ActivityFeedV2 = ({
getAvatarUrl,
getMentionAsync,
getTaskCollaborators,
getViewer,
hasTasks = true,
isDisabled = false,
isTimestampedCommentsEnabled = false,
Expand Down Expand Up @@ -72,6 +73,8 @@ const ActivityFeedV2 = ({
const intl = useIntl();
const scrollHandle = useActivityFeedScroll();
const currentUserId = currentUser?.id;
const scrollHandleRef = React.useRef<typeof scrollHandle | null>(null);
scrollHandleRef.current = scrollHandle;
const headerTitle = intl.formatMessage(commonMessages.sidebarActivityTitle);

const scrolledEntryIdRef = React.useRef<string | null>(null);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityFeedV2Props> = {}) =>
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[timestampedComment] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4', file_version: { id: '1' } }}
getViewer={mockGetViewer}
isTimestampedCommentsEnabled
{...props}
/>,
);

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', []);
});
});
});
7 changes: 7 additions & 0 deletions src/elements/content-sidebar/activity-feed-v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
Expand All @@ -94,6 +100,7 @@ export type ActivityFeedV2Props = {
getAvatarUrl?: GetAvatarUrl;
getMentionAsync?: (searchStr: string) => Promise<Array<Record<string, unknown>>>;
getTaskCollaborators?: (task: TaskNew) => Promise<TaskAssigneeCollection>;
getViewer?: () => ViewerHandle | null;
hasTasks?: boolean;
isDisabled?: boolean;
isTimestampedCommentsEnabled?: boolean;
Expand Down
Loading