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
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,4 @@ export const KEYS = {

/* ----------------- Other ----------------------- */
export const ONE_HOUR_MS = 3600000; // 60 * 60 * 1000
export const DEFAULT_VIDEO_FPS = 24;
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import TaskModal from '../TaskModal';
import FeedItemRow from './FeedItemRow';
import { serializeEditorContent } from './helpers';
import { transformFeedItem } from './transformers';
import { useTimeFormat } from './useTimeFormat';
import { useVideoTimestamp } from './useVideoTimestamp';

import type { ActivityFeedV2Props, TransformedFeedItem, UserContact } from './types';
Expand Down Expand Up @@ -290,13 +291,14 @@ const ActivityFeedV2 = ({
const isVideo = file?.extension ? FILE_EXTENSIONS.video.includes(file.extension) : false;
const fileVersionId = file?.file_version?.id;
const allowVideoTimestamps = isVideo && isTimestampedCommentsEnabled && Boolean(fileVersionId);
const { timeFormat, fps } = useTimeFormat(isVideo);

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

const editorVideoTimestamp = allowVideoTimestamps
? { formattedTimestamp, isPressed: isTimestampPressed, onPressedChange }
Expand Down Expand Up @@ -357,6 +359,7 @@ const ActivityFeedV2 = ({
<FeedItemRow
key={item.id}
currentUserId={currentUserId}
fps={fps}
isDisabled={isDisabled}
item={item}
onAnnotationCopyLink={onAnnotationCopyLink}
Expand All @@ -375,6 +378,7 @@ const ActivityFeedV2 = ({
onTaskEdit={onTaskUpdate ? handleTaskEdit : undefined}
onTaskView={onTaskView}
onVersionHistoryClick={onVersionHistoryClick}
timeFormat={timeFormat}
userSelectorProps={userSelectorProps}
/>
))}
Expand Down
22 changes: 20 additions & 2 deletions src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { ActivityFeed } from '@box/activity-feed';
import type { Annotation, AnnotationPermission } from '../../../common/types/annotations';
import type { BoxCommentPermission, CommentFeedItemType, FeedItemStatus } from '../../../common/types/feed';
import type { TaskCollabStatus, TaskNew } from '../../../common/types/tasks';
import type { TimeFormat } from './useTimeFormat';

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

import type { OnReplyDelete, OnReplyUpdate, TransformedFeedItem, UserSelectorProps } from './types';
Expand All @@ -29,6 +31,7 @@ import {

type FeedItemRowProps = {
currentUserId?: string;
fps: number;
isDisabled: boolean;
item: TransformedFeedItem;
onAnnotationCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
Expand Down Expand Up @@ -59,6 +62,7 @@ type FeedItemRowProps = {
onTaskEdit?: (task: TaskNew) => void;
onTaskView?: (id: string, isCreator: boolean) => void;
onVersionHistoryClick?: (version: { id: string; version_number: number }) => void;
timeFormat: TimeFormat;
userSelectorProps: UserSelectorProps;
};

Expand All @@ -78,6 +82,7 @@ const buildReplyPost =

const FeedItemRow = ({
currentUserId,
fps,
isDisabled,
item,
onAnnotationCopyLink,
Expand All @@ -96,6 +101,7 @@ const FeedItemRow = ({
onTaskEdit,
onTaskView,
onVersionHistoryClick,
timeFormat,
userSelectorProps,
}: FeedItemRowProps) => {
switch (item.type) {
Expand Down Expand Up @@ -134,10 +140,14 @@ const FeedItemRow = ({
};
const timestampMs = item.annotationTimestampMs;
const handleBadgeClick = timestampMs !== undefined ? () => seekVideoToMs(timestampMs) : undefined;
const commentAnnotationTarget =
item.annotationTarget && timestampMs !== undefined
? { ...item.annotationTarget, timestamp: formatByTimeFormat(timestampMs, timeFormat, fps) }
: item.annotationTarget;
return (
<ActivityFeed.List.ThreadedAnnotation
key={item.id}
annotationTarget={item.annotationTarget}
annotationTarget={commentAnnotationTarget}
isAnnotations={false}
isEditDisabled={isDisabled || item.isResolved}
isResolved={item.isResolved}
Expand Down Expand Up @@ -190,10 +200,18 @@ const FeedItemRow = ({
text: serialized.text,
});
};
const badgeTarget = annotationTargetToBadge(item.annotation.target);
const annotationBadgeTarget =
badgeTarget && 'timestamp' in badgeTarget && item.annotation.target?.location?.type === 'frame'
? {
...badgeTarget,
timestamp: formatByTimeFormat(item.annotation.target.location.value ?? 0, timeFormat, fps),
}
: badgeTarget;
return (
<ActivityFeed.List.ThreadedAnnotation
key={item.id}
annotationTarget={annotationTargetToBadge(item.annotation.target)}
annotationTarget={annotationBadgeTarget}
isAnnotations={false}
isEditDisabled={isDisabled || item.isResolved}
isResolved={item.isResolved}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ const mockAppActivity: TransformedFeedItem = {
};

const defaultProps = {
fps: 24,
isDisabled: false,
timeFormat: 'standard' as const,
userSelectorProps,
};

Expand Down Expand Up @@ -952,4 +954,83 @@ describe('elements/content-sidebar/activity-feed-v2/FeedItemRow', () => {
).not.toThrow();
});
});

describe('time format-aware badge rendering', () => {
test('should format comment badge timestamp using the current time format', () => {
const timestampedComment: TransformedCommentItem = {
...mockComment,
annotationTarget: { timestamp: '0:08', type: AnnotationBadgeType.Frame },
annotationTimestampMs: 8055,
};
render(<FeedItemRow {...defaultProps} fps={24} timeFormat="timecode" item={timestampedComment} />);

expect(lastThreadedAnnotationProps.annotationTarget).toEqual({
timestamp: '00:00:08:01',
type: AnnotationBadgeType.Frame,
});
});

test('should format comment badge timestamp as frame number', () => {
const timestampedComment: TransformedCommentItem = {
...mockComment,
annotationTarget: { timestamp: '0:10', type: AnnotationBadgeType.Frame },
annotationTimestampMs: 10000,
};
render(<FeedItemRow {...defaultProps} fps={24} timeFormat="frames" item={timestampedComment} />);

expect(lastThreadedAnnotationProps.annotationTarget).toEqual({
timestamp: '240',
type: AnnotationBadgeType.Frame,
});
});

test('should not modify comment badge when annotationTimestampMs is undefined', () => {
render(<FeedItemRow {...defaultProps} timeFormat="timecode" item={mockComment} />);

expect(lastThreadedAnnotationProps.annotationTarget).toBeUndefined();
});

test('should format annotation badge timestamp for frame-type annotations', () => {
const frameAnnotation: TransformedAnnotationItem = {
...mockAnnotation,
annotation: {
...mockAnnotation.annotation,
target: { location: { type: 'frame', value: 5000 }, type: 'region', x: 0, y: 0 },
} as TransformedAnnotationItem['annotation'],
};

const badge: AnnotationBadgeTargetType = { timestamp: '0:05', type: AnnotationBadgeType.Frame };
mockedAnnotationTargetToBadge.mockReturnValue(badge);

render(<FeedItemRow {...defaultProps} fps={30} timeFormat="frames" item={frameAnnotation} />);

expect(lastThreadedAnnotationProps.annotationTarget).toEqual({
timestamp: '150',
type: AnnotationBadgeType.Frame,
});
});

test('should not modify annotation badge for non-frame targets', () => {
const badge: AnnotationBadgeTargetType = { page: 3, type: AnnotationBadgeType.Point };
mockedAnnotationTargetToBadge.mockReturnValue(badge);

render(<FeedItemRow {...defaultProps} timeFormat="timecode" item={mockAnnotation} />);

expect(lastThreadedAnnotationProps.annotationTarget).toBe(badge);
});

test('should use standard format when timeFormat is standard', () => {
const timestampedComment: TransformedCommentItem = {
...mockComment,
annotationTarget: { timestamp: '0:08', type: AnnotationBadgeType.Frame },
annotationTimestampMs: 8055,
};
render(<FeedItemRow {...defaultProps} timeFormat="standard" item={timestampedComment} />);

expect(lastThreadedAnnotationProps.annotationTarget).toEqual({
timestamp: '0:08',
type: AnnotationBadgeType.Frame,
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from 'react';
import { act, render, screen } from '@testing-library/react';

import { formatByTimeFormat, useTimeFormat } from '../useTimeFormat';

const TestHarness = ({ enabled }: { enabled: boolean }) => {
const { timeFormat, fps } = useTimeFormat(enabled);
return (
<div>
<span data-testid="format">{timeFormat}</span>
<span data-testid="fps">{String(fps)}</span>
</div>
);
};

describe('useTimeFormat', () => {
afterEach(() => {
document.querySelectorAll('.bp-media-container').forEach(node => node.remove());
});

test('should return standard format and default fps when disabled', () => {
render(<TestHarness enabled={false} />);
expect(screen.getByTestId('format').textContent).toBe('standard');
expect(screen.getByTestId('fps').textContent).toBe('24');
});

test('should return standard format when no media container exists', () => {
render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('standard');
expect(screen.getByTestId('fps').textContent).toBe('24');
});

test('should read initial data attributes from media container', () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'timecode');
container.setAttribute('data-fps', '30');
document.body.appendChild(container);

render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('timecode');
expect(screen.getByTestId('fps').textContent).toBe('30');
});

test('should update when data-time-format attribute changes', async () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'standard');
container.setAttribute('data-fps', '24');
document.body.appendChild(container);

render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('standard');

await act(async () => {
container.setAttribute('data-time-format', 'frames');
});
expect(screen.getByTestId('format').textContent).toBe('frames');
});

test('should update when data-fps attribute changes', async () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'timecode');
container.setAttribute('data-fps', '24');
document.body.appendChild(container);

render(<TestHarness enabled />);
expect(screen.getByTestId('fps').textContent).toBe('24');

await act(async () => {
container.setAttribute('data-fps', '60');
});
expect(screen.getByTestId('fps').textContent).toBe('60');
});

test('should observe a late-appearing media container', async () => {
render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('standard');

await act(async () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'frames');
container.setAttribute('data-fps', '30');
document.body.appendChild(container);
});

expect(screen.getByTestId('format').textContent).toBe('frames');
expect(screen.getByTestId('fps').textContent).toBe('30');
});

test('should fall back to default fps when attribute is invalid', () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'timecode');
container.setAttribute('data-fps', 'bad');
document.body.appendChild(container);

render(<TestHarness enabled />);
expect(screen.getByTestId('fps').textContent).toBe('24');
});

test('should fall back to standard when data-time-format is absent', () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
document.body.appendChild(container);

render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('standard');
});

test('should reset to defaults when transitioning from enabled to disabled', async () => {
const container = document.createElement('div');
container.className = 'bp-media-container';
container.setAttribute('data-time-format', 'timecode');
container.setAttribute('data-fps', '30');
document.body.appendChild(container);

const { rerender } = render(<TestHarness enabled />);
expect(screen.getByTestId('format').textContent).toBe('timecode');
expect(screen.getByTestId('fps').textContent).toBe('30');

rerender(<TestHarness enabled={false} />);
expect(screen.getByTestId('format').textContent).toBe('standard');
expect(screen.getByTestId('fps').textContent).toBe('24');
});
});

describe('formatByTimeFormat', () => {
test('should format as standard time', () => {
expect(formatByTimeFormat(43500, 'standard', 24)).toBe('0:43');
expect(formatByTimeFormat(3661000, 'standard', 24)).toBe('1:01:01');
});

test('should format as timecode', () => {
expect(formatByTimeFormat(61500, 'timecode', 30)).toBe('00:01:01:15');
expect(formatByTimeFormat(0, 'timecode', 24)).toBe('00:00:00:00');
});

test('should format as frame number string', () => {
expect(formatByTimeFormat(10000, 'frames', 24)).toBe('240');
expect(formatByTimeFormat(1000, 'frames', 30)).toBe('30');
expect(formatByTimeFormat(0, 'frames', 24)).toBe('0');
});
});
Loading
Loading