diff --git a/src/elements/content-sidebar/activity-feed/annotations/AnnotationActivity.js b/src/elements/content-sidebar/activity-feed/annotations/AnnotationActivity.js index 163a51b612..1f231b88c0 100644 --- a/src/elements/content-sidebar/activity-feed/annotations/AnnotationActivity.js +++ b/src/elements/content-sidebar/activity-feed/annotations/AnnotationActivity.js @@ -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'; @@ -127,6 +128,11 @@ const AnnotationActivity = ({ targetAttachment: 'bottom right', }; + const isVideoAnnotation = target?.location?.type === 'frame'; + const annotationsMillisecondTimestampInHMMSS = isVideoAnnotation + ? convertMillisecondsToHMMSS(target.location.value) + : null; + return ( <>
- {hasVersions && ( + {hasVersions && !isVideoAnnotation && ( diff --git a/src/elements/content-sidebar/activity-feed/annotations/__tests__/AnnotationActivity.test.js b/src/elements/content-sidebar/activity-feed/annotations/__tests__/AnnotationActivity.test.js index 9583b01084..5aa9ba9cb1 100644 --- a/src/elements/content-sidebar/activity-feed/annotations/__tests__/AnnotationActivity.test.js +++ b/src/elements/content-sidebar/activity-feed/annotations/__tests__/AnnotationActivity.test.js @@ -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); + }); + }); }); diff --git a/src/elements/content-sidebar/activity-feed/common/activity-message/ActivityMessage.tsx b/src/elements/content-sidebar/activity-feed/common/activity-message/ActivityMessage.tsx index 0a4082415a..385a6b472c 100644 --- a/src/elements/content-sidebar/activity-feed/common/activity-message/ActivityMessage.tsx +++ b/src/elements/content-sidebar/activity-feed/common/activity-message/ActivityMessage.tsx @@ -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'; @@ -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 = { @@ -92,6 +94,8 @@ class ActivityMessage extends React.Component { id, intl, isEdited, + onClick = noop, + annotationsMillisecondTimestamp, tagged_message, translatedTaggedMessage, translationEnabled, @@ -110,7 +114,14 @@ class ActivityMessage extends React.Component { ) : (
- {formatTaggedMessage(commentToDisplay, id, false, getUserProfileUrl, intl)} + {annotationsMillisecondTimestamp + ? renderTimestampWithText( + annotationsMillisecondTimestamp, + onClick, + intl, + ` ${commentToDisplay}`, + ) + : formatTaggedMessage(commentToDisplay, id, false, getUserProfileUrl, intl)} {isEdited && ( diff --git a/src/elements/content-sidebar/activity-feed/common/activity-message/__tests__/ActivityMessage.test.js b/src/elements/content-sidebar/activity-feed/common/activity-message/__tests__/ActivityMessage.test.js index 5bdaca9e19..9167af1cb2 100644 --- a/src/elements/content-sidebar/activity-feed/common/activity-message/__tests__/ActivityMessage.test.js +++ b/src/elements/content-sidebar/activity-feed/common/activity-message/__tests__/ActivityMessage.test.js @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { mount, shallow } from 'enzyme'; +import { createIntl } from 'react-intl'; import { ActivityMessage } from '../ActivityMessage'; @@ -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(); + 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(); + expect(wrapper.find('button[aria-label="Seek to video timestamp"]').length).toBe(0); + expect(wrapper.find('.bcs-ActivityMessage').text()).toBe('test'); + }); + }); }); diff --git a/src/elements/content-sidebar/activity-feed/utils/formatTaggedMessage.js b/src/elements/content-sidebar/activity-feed/utils/formatTaggedMessage.js index 4804298a82..3f94dfbda0 100644 --- a/src/elements/content-sidebar/activity-feed/utils/formatTaggedMessage.js +++ b/src/elements/content-sidebar/activity-feed/utils/formatTaggedMessage.js @@ -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) => void, + intl: IntlShape, + textAfterTimestamp: string, +): React$Element => ( + <> +
+ +
+ {textAfterTimestamp} + +); + /** * Formats text containing a timestamp by wrapping the timestamp in a Link component * @param text The text containing the timestamp @@ -51,17 +79,7 @@ const formatTimestamp = (text: string, timestamp: string, intl: IntlShape): Reac } }; - const timestampLabel = intl.formatMessage(messages.activityMessageTimestampLabel); - return ( - <> -
- -
- {textAfterTimestamp} - - ); + return renderTimestampWithText(timestampInHHMMSS, handleClick, intl, textAfterTimestamp); }; // this regex matches one of the following regular expressions: