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
9 changes: 7 additions & 2 deletions src/common/types/annotations.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import type { BoxItemVersionMini, Reply, User } from './core';
import type { ActionItemError, Comment, FeedItemStatus } from './feed';

export type Frame = {
type: 'frame',
value: number,
};

export type Page = {
type: 'page',
value: number,
Expand All @@ -24,7 +29,7 @@ export type Rect = {
};

export type TargetDrawing = {
location: Page,
location: Frame | Page,
type: 'drawing',
};

Expand All @@ -35,7 +40,7 @@ export type TargetHighlight = {
};

export type TargetRegion = {
location: Page,
location: Frame | Page,
shape?: Rect,
type: 'region',
};
Expand Down
8 changes: 6 additions & 2 deletions src/elements/content-preview/ContentPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import CustomPreviewWrapper, { type ContentPreviewChildProps } from './CustomPre
import { withLogger } from '../common/logger';
import { PREVIEW_FIELDS_TO_FETCH } from '../../utils/fields';
import { mark } from '../../utils/performance';
import { convertTimestampToSeconds } from '../../utils/timestamp';
import { isFeatureEnabled, withFeatureConsumer, withFeatureProvider } from '../common/feature-checking';
// $FlowFixMe
import { withBlueprintModernization } from '../common/withBlueprintModernization';
Expand Down Expand Up @@ -230,7 +231,8 @@ type PreviewLibraryError = {
error: ErrorType,
};

const startAtTypes = {
const startAtTypes: $ReadOnly<{ frame: 'seconds', page: 'pages' }> = {
frame: 'seconds',
page: 'pages',
};
const InvalidIdError = new Error('Invalid id for Preview!');
Expand Down Expand Up @@ -1448,10 +1450,12 @@ class ContentPreview extends React.PureComponent<Props, State> {
const viewer = this.getViewer();

if (unit && annotationFileVersionId && annotationFileVersionId !== currentPreviewFileVersionId) {
// Frame.value is milliseconds; Preview SDK startAt expects seconds for video.
const value = location.type === 'frame' ? convertTimestampToSeconds(location.value) : location.value;
this.setState({
startAt: {
unit,
value: location.value,
value,
},
});
}
Expand Down
40 changes: 39 additions & 1 deletion src/elements/content-preview/__tests__/ContentPreview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1517,7 +1517,7 @@ describe('elements/content-preview/ContentPreview', () => {
annotationFileVersionId | selectedVersionId | locationType | setStateCount
${'123'} | ${'124'} | ${'page'} | ${1}
${'124'} | ${'124'} | ${'page'} | ${0}
${'123'} | ${'124'} | ${'frame'} | ${0}
${'123'} | ${'124'} | ${'frame'} | ${1}
${'123'} | ${'124'} | ${''} | ${0}
${undefined} | ${'124'} | ${'page'} | ${0}
`(
Expand Down Expand Up @@ -1551,6 +1551,44 @@ describe('elements/content-preview/ContentPreview', () => {
},
);

test('should set startAt with seconds-converted value for cross-version frame annotations', () => {
const annotation = {
id: '123',
file_version: { id: '123' },
target: { location: { type: 'frame', value: 4623 } },
};
const wrapper = getWrapper();
const instance = wrapper.instance();
wrapper.setState({ selectedVersion: { id: '124' } });
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit: jest.fn() });
instance.setState = jest.fn();

instance.handleAnnotationSelect(annotation);

expect(instance.setState).toHaveBeenCalledWith({
startAt: { unit: 'seconds', value: 4.623 },
});
});

test('should set startAt with the page number for cross-version page annotations', () => {
const annotation = {
id: '123',
file_version: { id: '123' },
target: { location: { type: 'page', value: 5 } },
};
const wrapper = getWrapper();
const instance = wrapper.instance();
wrapper.setState({ selectedVersion: { id: '124' } });
jest.spyOn(instance, 'getViewer').mockReturnValue({ emit: jest.fn() });
instance.setState = jest.fn();

instance.handleAnnotationSelect(annotation);

expect(instance.setState).toHaveBeenCalledWith({
startAt: { unit: 'pages', value: 5 },
});
});

test.each`
annotationFileVersionId | selectedVersionId | locationType | deferScrollToOnload
${'123'} | ${'124'} | ${'frame'} | ${true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -718,5 +718,33 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
type: 'point',
});
});

test('should map a sub-hour frame-location target to a frame badge with M:SS timestamp', () => {
const target = {
location: { type: 'frame', value: 4623 },
shape: { height: 10, type: 'rect', width: 20, x: 5, y: 5 },
type: 'region',
};
expect(annotationTargetToBadge(target as unknown as Annotation['target'])).toEqual({
timestamp: '0:04',
type: 'frame',
});
});

test('should map an over-hour frame-location target to a frame badge with H:MM:SS timestamp', () => {
const target = { location: { type: 'frame', value: 3661000 }, type: 'region' };
expect(annotationTargetToBadge(target as unknown as Annotation['target'])).toEqual({
timestamp: '1:01:01',
type: 'frame',
});
});

test('should default the frame timestamp to 0:00 when location.value is missing', () => {
const target = { location: { type: 'frame' }, type: 'region' };
expect(annotationTargetToBadge(target as unknown as Annotation['target'])).toEqual({
timestamp: '0:00',
type: 'frame',
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
TextNodeV2 as TextNode,
} from '@box/threaded-annotations';

import { convertMillisecondsToTimestamp } from '../../../utils/timestamp';

import type { Annotation, Target } from '../../../common/types/annotations';
import type { AppActivityItem as BUIEAppActivityItem, Comment, FeedItem } from '../../../common/types/feed';
import type { BoxItemVersion, User } from '../../../common/types/core';
Expand Down Expand Up @@ -139,6 +141,13 @@ export const transformCommentToMessages = (comment: Comment): TextMessageType[]
export const annotationTargetToBadge = (target?: Target): AnnotationBadgeTargetType | undefined => {
if (!target) return undefined;

if (target.location?.type === 'frame') {
return {
timestamp: convertMillisecondsToTimestamp(target.location.value ?? 0),
type: AnnotationBadgeType.Frame,
};
}

const page = target.location?.value ?? 0;

switch (target.type) {
Expand Down
28 changes: 27 additions & 1 deletion src/utils/__tests__/timestamp.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { convertTimestampToSeconds, convertMillisecondsToHMMSS, convertSecondsToHMMSS } from '../timestamp';
import {
convertMillisecondsToHMMSS,
convertMillisecondsToTimestamp,
convertSecondsToHMMSS,
convertTimestampToSeconds,
} from '../timestamp';

describe('utils/timestamp', () => {
describe('convertMillisecondsToHMMSS', () => {
Expand Down Expand Up @@ -62,6 +67,27 @@ describe('utils/timestamp', () => {
});
});

describe('convertMillisecondsToTimestamp', () => {
test('should format sub-hour durations as M:SS', () => {
expect(convertMillisecondsToTimestamp(0)).toBe('0:00');
expect(convertMillisecondsToTimestamp(1000)).toBe('0:01');
expect(convertMillisecondsToTimestamp(4623)).toBe('0:04');
expect(convertMillisecondsToTimestamp(60000)).toBe('1:00');
expect(convertMillisecondsToTimestamp(3599999)).toBe('59:59');
});

test('should format hour-or-longer durations as H:MM:SS', () => {
expect(convertMillisecondsToTimestamp(3600000)).toBe('1:00:00');
expect(convertMillisecondsToTimestamp(3661000)).toBe('1:01:01');
expect(convertMillisecondsToTimestamp(90061000)).toBe('25:01:01');
});

test('should handle invalid input', () => {
expect(convertMillisecondsToTimestamp(NaN)).toBe('0:00');
expect(convertMillisecondsToTimestamp(-1)).toBe('0:00');
});
});

describe('convertSecondsToHHMMSS', () => {
test('should convert seconds to HH:MM:SS format correctly', () => {
expect(convertSecondsToHMMSS(0)).toBe('0:00:00');
Expand Down
1 change: 1 addition & 0 deletions src/utils/timestamp.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import type { IntlShape } from 'react-intl';

declare export function convertTimestampToSeconds(timestamp: number): number;
declare export function convertMillisecondsToHMMSS(timestampInMilliseconds: number): string;
declare export function convertMillisecondsToTimestamp(timestampInMilliseconds: number): string;
declare export function convertSecondsToHMMSS(seconds: number): string;
21 changes: 20 additions & 1 deletion src/utils/timestamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,23 @@ const convertSecondsToHMMSS = (seconds: number): string => {
return `${hours.toString()}:${minutes.toString().padStart(2, '0')}:${secondsValue.toString().padStart(2, '0')}`;
};

export { convertTimestampToSeconds, convertMillisecondsToHMMSS, convertSecondsToHMMSS };
/**
* Converts milliseconds to a video-style timestamp, omitting the hours field for durations under one hour.
* @param timestampInMilliseconds The timestamp in milliseconds
* @returns The formatted timestamp: M:SS when under an hour, H:MM:SS otherwise
*/
const convertMillisecondsToTimestamp = (timestampInMilliseconds: number): string => {
if (!timestampInMilliseconds || timestampInMilliseconds < 0) {
return '0:00';
}
const hours = Math.floor(timestampInMilliseconds / ONE_HOUR_MS);
const minutes = Math.floor((timestampInMilliseconds % ONE_HOUR_MS) / 60000);
const seconds = Math.floor((timestampInMilliseconds % 60000) / 1000);
const paddedSeconds = seconds.toString().padStart(2, '0');
if (hours === 0) {
return `${minutes.toString()}:${paddedSeconds}`;
}
return `${hours.toString()}:${minutes.toString().padStart(2, '0')}:${paddedSeconds}`;
};

export { convertMillisecondsToHMMSS, convertMillisecondsToTimestamp, convertSecondsToHMMSS, convertTimestampToSeconds };
Loading