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
6 changes: 5 additions & 1 deletion src/elements/content-sidebar/ActivitySidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { mark } from '../../utils/performance';
import { withAnnotatorContext } from '../common/annotator-context';
import { withAPIContext } from '../common/api-context';
import { withErrorBoundary } from '../common/error-boundary';
import { withFeatureConsumer, isFeatureEnabled } from '../common/feature-checking';
import { withFeatureConsumer, isFeatureEnabled, getFeatureConfig } from '../common/feature-checking';
import { withLogger } from '../common/logger';
import { withRouterAndRef } from '../common/routing';
import ActivitySidebarFilter from './ActivitySidebarFilter';
Expand Down Expand Up @@ -1409,6 +1409,8 @@ class ActivitySidebar extends React.PureComponent<Props, State> {

if (isThreadedRepliesV2Enabled) {
const label = `${elementId}${elementId === '' ? '' : '_'}${SIDEBAR_VIEW_ACTIVITY}`;
const timestampedCommentsConfig = getFeatureConfig(features, 'activityFeed.timestampedComments');
const isTimestampedCommentsEnabled = timestampedCommentsConfig?.enabled === true;
return (
<div
aria-labelledby={label}
Expand All @@ -1423,12 +1425,14 @@ class ActivitySidebar extends React.PureComponent<Props, State> {
createTask={this.createTask}
currentUser={currentUser}
feedItems={this.getFilteredFeedItems()}
file={file}
getApproverWithQuery={this.getApprover}
getAvatarUrl={this.getAvatarUrl}
getMentionAsync={this.getMentionAsync}
getTaskCollaborators={this.getTaskCollaborators}
hasTasks={this.props.hasTasks}
isDisabled={isDisabled}
isTimestampedCommentsEnabled={isTimestampedCommentsEnabled}
onAnnotationCopyLink={onAnnotationCopyLink}
onAnnotationDelete={this.handleAnnotationDelete}
onAnnotationEdit={this.handleAnnotationEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import TaskModal from '../TaskModal';
import FeedItemRow from './FeedItemRow';
import { serializeEditorContent } from './helpers';
import { transformFeedItem } from './transformers';
import { useVideoTimestamp } from './useVideoTimestamp';

import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types';
import type { TaskAssigneeCollection, TaskNew } from '../../../common/types/tasks';

import { FILE_EXTENSIONS } from '../../common/item/constants';
import { TASK_COMPLETION_RULE_ALL, TASK_EDIT_MODE_EDIT, TASK_TYPE_APPROVAL } from '../../../constants';

import commonMessages from '../../common/messages';
Expand All @@ -35,12 +37,14 @@ const ActivityFeedV2 = ({
createTask,
currentUser,
feedItems,
file,
getApproverWithQuery,
getAvatarUrl,
getMentionAsync,
getTaskCollaborators,
hasTasks = true,
isDisabled = false,
isTimestampedCommentsEnabled = false,
onAnnotationCopyLink,
onAnnotationDelete,
onAnnotationEdit,
Expand Down Expand Up @@ -278,21 +282,40 @@ const ActivityFeedV2 = ({
}
}, [currentUserId, filteredItems, scrollHandle]);

const isVideo = file?.extension ? FILE_EXTENSIONS.video.includes(file.extension) : false;
const fileVersionId = file?.file_version?.id;
const allowVideoTimestamps = isVideo && isTimestampedCommentsEnabled && Boolean(fileVersionId);

const {
formattedTimestamp,
isPressed: isTimestampPressed,
onPressedChange,
timestampMs,
} = useVideoTimestamp(allowVideoTimestamps);

const editorVideoTimestamp = allowVideoTimestamps
? { formattedTimestamp, isPressed: isTimestampPressed, onPressedChange }
: undefined;

const handleCommentPost = React.useCallback(
async (content: unknown) => {
if (!onCommentCreate) return;
const serialized = serializeEditorContent(content);
if (!serialized || !serialized.text) return;
const text =
allowVideoTimestamps && isTimestampPressed && fileVersionId
? `#[timestamp:${timestampMs},versionId:${fileVersionId}] ${serialized.text}`
Comment thread
jackiejou marked this conversation as resolved.
: serialized.text;
try {
const snapshot = new Set(filteredItems.map(item => item.id));
await onCommentCreate(serialized.text, serialized.hasMention);
await onCommentCreate(text, serialized.hasMention);
knownIdsBeforePostRef.current = snapshot;
} catch (error) {
// eslint-disable-next-line no-console
console.error('ActivityFeedV2: failed to post comment', error);
}
},
[filteredItems, onCommentCreate],
[allowVideoTimestamps, filteredItems, fileVersionId, isTimestampPressed, onCommentCreate, timestampMs],
);

return (
Expand Down Expand Up @@ -358,6 +381,7 @@ const ActivityFeedV2 = ({
disableComponent={isDisabled || !currentUser}
onPost={handleCommentPost}
userSelectorProps={userSelectorProps}
videoTimestamp={editorVideoTimestamp}
/>
</div>
</ActivityFeed.Root>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { TaskCollabStatus, TaskNew } from '../../../common/types/tasks';

import { dispatchReplyDelete, dispatchReplyEdit, logEditError, serializeEditorContent } from './helpers';
import { annotationTargetToBadge } from './transformers';
import { seekVideoToMs } from './useVideoTimestamp';

import type { OnReplyDelete, OnReplyUpdate, TransformedFeedItem, UserSelectorProps } from './types';

Expand Down Expand Up @@ -128,6 +129,8 @@ const FeedItemRow = ({
text: serialized.text,
});
};
const timestampMs = item.annotationTimestampMs;
const handleBadgeClick = timestampMs !== undefined ? () => seekVideoToMs(timestampMs) : undefined;
return (
<ActivityFeed.List.ThreadedAnnotation
key={item.id}
Expand All @@ -136,6 +139,7 @@ const FeedItemRow = ({
isEditDisabled={isDisabled || item.isResolved}
isResolved={item.isResolved}
messages={item.messages}
onAnnotationBadgeClick={handleBadgeClick}
onAvatarClick={noop}
onCopyLink={onCommentCopyLink ? (id: string) => onCommentCopyLink({ id }) : undefined}
onDelete={handleDelete}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,145 @@ describe('elements/content-sidebar/activity-feed-v2/ActivityFeedV2', () => {
});
});

describe('video timestamp', () => {
const mountVideo = (currentTime: number = 0) => {
const container = document.createElement('div');
container.className = 'bp-media-container';
const video = document.createElement('video');
Object.defineProperty(video, 'currentTime', {
configurable: true,
value: currentTime,
writable: true,
});
Object.defineProperty(video, 'paused', { configurable: true, value: true, writable: true });
video.pause = jest.fn();
container.appendChild(video);
document.body.appendChild(container);
return { cleanup: () => container.remove(), video };
};

test('should not pass videoTimestamp when file is not a video', () => {
const { cleanup } = mountVideo();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'pdf', file_version: { id: '1' } }}
isTimestampedCommentsEnabled
/>,
);
expect(lastEditorProps.videoTimestamp).toBeUndefined();
} finally {
cleanup();
}
});

test('should not pass videoTimestamp when isTimestampedCommentsEnabled is false', () => {
const { cleanup } = mountVideo();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4', file_version: { id: '1' } }}
/>,
);
expect(lastEditorProps.videoTimestamp).toBeUndefined();
} finally {
cleanup();
}
});

test('should pass videoTimestamp with default 0:00 for videos when enabled', () => {
const { cleanup } = mountVideo();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4', file_version: { id: '1' } }}
isTimestampedCommentsEnabled
/>,
);
expect(lastEditorProps.videoTimestamp).toEqual({
formattedTimestamp: '0:00',
isPressed: false,
onPressedChange: expect.any(Function),
});
} finally {
cleanup();
}
});

test('should prepend timestamp markup to posted text when toggle is pressed', async () => {
const { cleanup, video } = mountVideo();
mockSerializeMentionMarkup.mockReturnValue({ hasMention: false, text: 'great frame' });
const onCommentCreate = jest.fn();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4', file_version: { id: '99' } }}
isTimestampedCommentsEnabled
onCommentCreate={onCommentCreate}
/>,
);
Object.defineProperty(video, 'currentTime', { configurable: true, value: 8.055, writable: true });
await act(async () => {
lastEditorProps.videoTimestamp?.onPressedChange(true);
});
await act(async () => {
await lastEditorProps.onPost?.({ type: 'doc', content: [] });
});
expect(onCommentCreate).toHaveBeenCalledWith('#[timestamp:8055,versionId:99] great frame', false);
} finally {
cleanup();
}
});

test('should post the original text when the toggle is not pressed', async () => {
const { cleanup } = mountVideo();
mockSerializeMentionMarkup.mockReturnValue({ hasMention: false, text: 'plain comment' });
const onCommentCreate = jest.fn();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4', file_version: { id: '99' } }}
isTimestampedCommentsEnabled
onCommentCreate={onCommentCreate}
/>,
);
await act(async () => {
await lastEditorProps.onPost?.({ type: 'doc', content: [] });
});
expect(onCommentCreate).toHaveBeenCalledWith('plain comment', false);
} finally {
cleanup();
}
});

test('should not pass videoTimestamp when fileVersionId is missing', () => {
const { cleanup } = mountVideo();
try {
render(
<ActivityFeedV2
currentUser={mockCurrentUser}
feedItems={[] as ActivityFeedV2Props['feedItems']}
file={{ extension: 'mp4' }}
isTimestampedCommentsEnabled
/>,
);
expect(lastEditorProps.videoTimestamp).toBeUndefined();
} finally {
cleanup();
}
});
});

describe('filter controls', () => {
test('should default showResolved and showOnlyMentionsMe to false in the filter menu', () => {
render(<ActivityFeedV2 currentUser={mockCurrentUser} feedItems={[] as ActivityFeedV2Props['feedItems']} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { render, screen } from '../../../../test-utils/testing-library';
import FeedItemRow from '../FeedItemRow';
import { dispatchReplyDelete, dispatchReplyEdit, logEditError, serializeEditorContent } from '../helpers';
import { annotationTargetToBadge } from '../transformers';
import { seekVideoToMs } from '../useVideoTimestamp';

import type { TaskNew } from '../../../../common/types/tasks';
import type {
Expand Down Expand Up @@ -52,10 +53,15 @@ jest.mock('../transformers', () => ({
annotationTargetToBadge: jest.fn(),
}));

jest.mock('../useVideoTimestamp', () => ({
seekVideoToMs: jest.fn(),
}));

const mockedSerializeEditorContent = jest.mocked(serializeEditorContent);
const mockedDispatchReplyDelete = jest.mocked(dispatchReplyDelete);
const mockedDispatchReplyEdit = jest.mocked(dispatchReplyEdit);
const mockedAnnotationTargetToBadge = jest.mocked(annotationTargetToBadge);
const mockedSeekVideoToMs = jest.mocked(seekVideoToMs);

const userSelectorProps: UserSelectorProps = {
ariaRoleDescription: 'user selector',
Expand Down Expand Up @@ -393,6 +399,24 @@ describe('elements/content-sidebar/activity-feed-v2/FeedItemRow', () => {
render(<FeedItemRow {...defaultProps} item={mockComment} />);
expect(lastThreadedAnnotationProps.onEditError).toBe(logEditError);
});

test('should not pass onAnnotationBadgeClick when the comment has no timestamp markup', () => {
render(<FeedItemRow {...defaultProps} item={mockComment} />);
expect(lastThreadedAnnotationProps.onAnnotationBadgeClick).toBeUndefined();
});

test('should seek the video on badge click when the comment carries a timestamp', () => {
const timestampedComment: TransformedCommentItem = {
...mockComment,
annotationTarget: { timestamp: '0:08', type: AnnotationBadgeType.Frame },
annotationTimestampMs: 8055,
};
render(<FeedItemRow {...defaultProps} item={timestampedComment} />);

lastThreadedAnnotationProps.onAnnotationBadgeClick?.('comment-1');

expect(mockedSeekVideoToMs).toHaveBeenCalledWith(8055);
});
});

describe('annotation rendering', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -689,12 +689,14 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
const result = extractTimestampMarkup('#[timestamp:8055,versionId:2390295731268] wowo');
expect(result.cleanText).toBe('wowo');
expect(result.target).toEqual({ timestamp: '0:08', type: 'frame' });
expect(result.timestampMs).toBe(8055);
});

test('should extract timestamp markup without versionId', () => {
const result = extractTimestampMarkup('#[timestamp:65000] hello');
expect(result.cleanText).toBe('hello');
expect(result.target).toEqual({ timestamp: '1:05', type: 'frame' });
expect(result.timestampMs).toBe(65000);
});

test('should leave timestamp markup that does not anchor at the start of the message untouched', () => {
Expand All @@ -719,6 +721,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
const result = extractTimestampMarkup('#[timestamp:99999999999999999999] hello');
expect(result.cleanText).toBe('hello');
expect(result.target).toBeUndefined();
expect(result.timestampMs).toBeUndefined();
});
});

Expand Down Expand Up @@ -756,6 +759,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
expect(result!.type).toBe('comment');
if (result!.type === 'comment') {
expect(result.annotationTarget).toEqual({ timestamp: '0:08', type: 'frame' });
expect(result.annotationTimestampMs).toBe(8055);
}
});

Expand All @@ -774,6 +778,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
const result = transformFeedItem(comment as unknown as FeedItem);
if (result!.type === 'comment') {
expect(result.annotationTarget).toBeUndefined();
expect(result.annotationTimestampMs).toBeUndefined();
}
});
});
Expand Down
Loading
Loading