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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { GetAvatarUrlCallback, GetProfileUrlCallback } from '../../../commo
import type { SelectorItems, User, BoxItem } from '../../../../common/types/core';

import IconAnnotation from '../../../../icons/two-toned/IconAnnotation';
import { convertMillisecondsToHMMSS } from '../../../../utils/timestamp';

import './AnnotationActivity.scss';

Expand Down Expand Up @@ -127,6 +128,11 @@ const AnnotationActivity = ({
targetAttachment: 'bottom right',
};

const isVideoAnnotation = target?.location?.type === 'frame';
const annotationsMillisecondTimestampInHMMSS = isVideoAnnotation
? convertMillisecondsToHMMSS(target.location.value)
: null;

return (
<>
<SelectableActivityCard
Expand Down Expand Up @@ -158,7 +164,7 @@ const AnnotationActivity = ({
</div>
<div className="bcs-AnnotationActivity-timestamp">
<ActivityTimestamp date={createdAtTimestamp} />
{hasVersions && (
{hasVersions && !isVideoAnnotation && (
<AnnotationActivityLink
className="bcs-AnnotationActivity-link"
data-resin-target="annotationLink"
Expand Down Expand Up @@ -189,6 +195,8 @@ const AnnotationActivity = ({
<ActivityMessage
getUserProfileUrl={getUserProfileUrl}
id={id}
annotationsMillisecondTimestamp={annotationsMillisecondTimestampInHMMSS}
onClick={handleSelect}
isEdited={isEdited && !isResolved}
tagged_message={message}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,153 @@ describe('elements/content-sidebar/ActivityFeed/annotations/AnnotationActivity',
expect(event.stopPropagation).not.toHaveBeenCalled();
});
});

describe('video annotations', () => {
const mockVideoAnnotation = {
...mockAnnotation,
target: {
location: {
type: 'frame',
value: 60000, // 1 minute in milliseconds
},
},
};

test('should detect video annotation when target location type is frame', () => {
const wrapper = getWrapper({ item: mockVideoAnnotation });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBe('0:01:00');
});

test('should not show version link for video annotations even when hasVersions is true', () => {
const wrapper = getWrapper({
item: mockVideoAnnotation,
hasVersions: true,
isCurrentVersion: true,
});

expect(wrapper.exists('AnnotationActivityLink')).toBe(false);
});

test('should pass correct timestamp format to ActivityMessage for video annotations', () => {
const videoAnnotationWithTimestamp = {
...mockVideoAnnotation,
target: {
location: {
type: 'frame',
value: 3661000, // 1 hour, 1 minute, 1 second
},
},
};

const wrapper = getWrapper({ item: videoAnnotationWithTimestamp });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBe('1:01:01');
});

test('should handle zero timestamp for video annotations', () => {
const videoAnnotationWithZeroTimestamp = {
...mockVideoAnnotation,
target: {
location: {
type: 'frame',
value: 0,
},
},
};

const wrapper = getWrapper({ item: videoAnnotationWithZeroTimestamp });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBe('0:00:00');
});

test('should handle undefined timestamp for video annotations', () => {
const videoAnnotationWithUndefinedTimestamp = {
...mockVideoAnnotation,
target: {
location: {
type: 'frame',
value: undefined,
},
},
};

const wrapper = getWrapper({ item: videoAnnotationWithUndefinedTimestamp });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBe('0:00:00');
});

test('should not pass timestamp to ActivityMessage for non-video annotations', () => {
const regularAnnotation = {
...mockAnnotation,
target: {
location: {
type: 'page',
value: 1,
},
},
};

const wrapper = getWrapper({ item: regularAnnotation });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBeFalsy();
});

test('should not pass timestamp to ActivityMessage when target location type is missing', () => {
const annotationWithoutType = {
...mockAnnotation,
target: {
location: {
value: 60000,
},
},
};

const wrapper = getWrapper({ item: annotationWithoutType });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBeFalsy();
});

test('should not pass timestamp to ActivityMessage when target is missing', () => {
const annotationWithoutTarget = {
...mockAnnotation,
target: {
location: {
value: 1,
},
},
};

const wrapper = getWrapper({ item: annotationWithoutTarget });
const activityMessage = wrapper.find('ForwardRef(withFeatureConsumer(ActivityMessage))');

expect(activityMessage.prop('annotationsMillisecondTimestamp')).toBeFalsy();
});

test('should show version link for non-video annotations when hasVersions is true', () => {
const regularAnnotation = {
...mockAnnotation,
target: {
location: {
type: 'page',
value: 1,
},
},
};

const wrapper = getWrapper({
item: regularAnnotation,
hasVersions: true,
isCurrentVersion: true,
});

expect(wrapper.exists('AnnotationActivityLink')).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import LoadingIndicator, { LoadingIndicatorSize } from '../../../../../component
import ShowOriginalButton from './ShowOriginalButton';
import TranslateButton from './TranslateButton';

import formatTaggedMessage from '../../utils/formatTaggedMessage';
import formatTaggedMessage, { renderTimestampWithText } from '../../utils/formatTaggedMessage';
import { withFeatureConsumer, isFeatureEnabled } from '../../../../common/feature-checking';

import messages from './messages';
Expand All @@ -22,11 +22,13 @@ export interface ActivityMessageProps extends WrappedComponentProps {
getUserProfileUrl?: GetProfileUrlCallback;
id: string;
isEdited?: boolean;
onClick?: () => void;
onTranslate?: ({ id, tagged_message }: { id: string; tagged_message: string }) => void;
tagged_message: string;
translatedTaggedMessage?: string;
translationEnabled?: boolean;
translationFailed?: boolean | null;
annotationsMillisecondTimestamp?: string | null;
}

type State = {
Expand Down Expand Up @@ -92,6 +94,8 @@ class ActivityMessage extends React.Component<ActivityMessageProps, State> {
id,
intl,
isEdited,
onClick = noop,
annotationsMillisecondTimestamp,
tagged_message,
translatedTaggedMessage,
translationEnabled,
Expand All @@ -110,7 +114,14 @@ class ActivityMessage extends React.Component<ActivityMessageProps, State> {
) : (
<div className="bcs-ActivityMessage">
<MessageWrapper>
{formatTaggedMessage(commentToDisplay, id, false, getUserProfileUrl, intl)}
{annotationsMillisecondTimestamp
? renderTimestampWithText(
annotationsMillisecondTimestamp,
onClick,
intl,
` ${commentToDisplay}`,
)
: formatTaggedMessage(commentToDisplay, id, false, getUserProfileUrl, intl)}
{isEdited && (
<span className="bcs-ActivityMessage-edited">
<FormattedMessage {...messages.activityMessageEdited} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { act } from 'react';
import { mount, shallow } from 'enzyme';
import { createIntl } from 'react-intl';

import { ActivityMessage } from '../ActivityMessage';

Expand Down Expand Up @@ -184,4 +185,33 @@ describe('elements/content-sidebar/ActivityFeed/common/activity-message', () =>

expect(wrapper.exists({ id: 'be.contentSidebar.activityFeed.common.editedMessage' })).toBe(expected);
});

describe('video annotation', () => {
test('should render timestamp with text when annotationsMillisecondTimestamp is provided', () => {
const onClick = jest.fn();
const videoAnnotation = {
annotationsMillisecondTimestamp: '0:01:00',
tagged_message: 'test',
onClick,
};

const intl = createIntl({ locale: 'en' });
const wrapper = mount(<ActivityMessage id="123" {...videoAnnotation} intl={intl} />);
expect(wrapper.find('button[aria-label="Seek to video timestamp"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Seek to video timestamp"]').text()).toBe('0:01:00');
wrapper.find('button[aria-label="Seek to video timestamp"]').simulate('click');
expect(onClick).toHaveBeenCalled();
});

test('should render original message when annotationsMillisecondTimestamp is not provided', () => {
const comment = {
annotationsMillisecondTimestamp: undefined,
tagged_message: 'test',
};
const intl = createIntl({ locale: 'en' });
const wrapper = mount(<ActivityMessage id="123" {...comment} intl={intl} />);
expect(wrapper.find('button[aria-label="Seek to video timestamp"]').length).toBe(0);
expect(wrapper.find('.bcs-ActivityMessage').text()).toBe('test');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import UserLink from '../common/user-link';
import messages from '../common/activity-message/messages';
import { convertTimestampToSeconds, convertMillisecondsToHMMSS } from '../../../../utils/timestamp';

/**
* Renders the timestamp button and remaining text
* @param timestampInHHMMSS The formatted timestamp string (HH:MM:SS)
* @param timestampLabel The aria label for the timestamp button
* @param handleClick The click handler for the timestamp button
* @param textAfterTimestamp The text that comes after the timestamp
* @returns A React Fragment with timestamp button and text
*/
export const renderTimestampWithText = (
timestampInHHMMSS: string,
handleClick: (e: SyntheticMouseEvent<HTMLButtonElement>) => void,
intl: IntlShape,
textAfterTimestamp: string,
): React$Element<any> => (
<>
<div className="bcs-ActivityMessage-timestamp">
<button
aria-label={intl.formatMessage(messages.activityMessageTimestampLabel)}
type="button"
onClick={handleClick}
>
{timestampInHHMMSS}
</button>
</div>
{textAfterTimestamp}
</>
);

/**
* Formats text containing a timestamp by wrapping the timestamp in a Link component
* @param text The text containing the timestamp
Expand Down Expand Up @@ -51,17 +79,7 @@ const formatTimestamp = (text: string, timestamp: string, intl: IntlShape): Reac
}
};

const timestampLabel = intl.formatMessage(messages.activityMessageTimestampLabel);
return (
<>
<div className="bcs-ActivityMessage-timestamp">
<button aria-label={timestampLabel} type="button" onClick={handleClick}>
{timestampInHHMMSS}
</button>
</div>
{textAfterTimestamp}
</>
);
return renderTimestampWithText(timestampInHHMMSS, handleClick, intl, textAfterTimestamp);
};

// this regex matches one of the following regular expressions:
Expand Down