From eb368388a843ab20bd3a479c58a3d0ae05396f2b Mon Sep 17 00:00:00 2001 From: Jackie Jou Date: Fri, 5 Jun 2026 20:48:50 -0700 Subject: [PATCH] feat(activity-feed-v2): show timestamp badge for video comments Strips a leading `#[timestamp:NN,versionId:NN]` marker from comment text and renders a frame badge with the formatted timestamp on the comment row. The marker is parsed once per comment in transformFeedItem and the result shared with commentToTextMessage so the rendered body and the badge cannot diverge. Pathological inputs (non-anchored, malformed, or overflowing the safe-integer range) silently skip the badge instead of producing garbage. --- .../activity-feed-v2/FeedItemRow.tsx | 1 + .../__tests__/transformers.test.ts | 103 ++++++++++++++++++ .../activity-feed-v2/transformers.ts | 47 ++++++-- .../content-sidebar/activity-feed-v2/types.ts | 3 +- 4 files changed, 143 insertions(+), 11 deletions(-) diff --git a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx index 04a3c0059f..d07105cb1a 100644 --- a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx @@ -131,6 +131,7 @@ const FeedItemRow = ({ return ( { }); }); + describe('extractTimestampMarkup()', () => { + test('should return text unchanged when no timestamp markup is present', () => { + expect(extractTimestampMarkup('regular comment')).toEqual({ cleanText: 'regular comment' }); + }); + + test('should return empty input unchanged', () => { + expect(extractTimestampMarkup('')).toEqual({ cleanText: '' }); + }); + + test('should extract timestamp markup with versionId and return a frame badge target', () => { + const result = extractTimestampMarkup('#[timestamp:8055,versionId:2390295731268] wowo'); + expect(result.cleanText).toBe('wowo'); + expect(result.target).toEqual({ timestamp: '0:08', type: 'frame' }); + }); + + 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' }); + }); + + test('should leave timestamp markup that does not anchor at the start of the message untouched', () => { + const result = extractTimestampMarkup('see #[timestamp:8055,versionId:1] this'); + expect(result.cleanText).toBe('see #[timestamp:8055,versionId:1] this'); + expect(result.target).toBeUndefined(); + }); + + test('should return empty cleanText when the message is only timestamp markup', () => { + const result = extractTimestampMarkup('#[timestamp:8055,versionId:1]'); + expect(result.cleanText).toBe(''); + expect(result.target).toEqual({ timestamp: '0:08', type: 'frame' }); + }); + + test('should leave malformed timestamp markup untouched', () => { + const result = extractTimestampMarkup('#[timestamp:abc] hello'); + expect(result.cleanText).toBe('#[timestamp:abc] hello'); + expect(result.target).toBeUndefined(); + }); + + test('should drop the badge when the timestamp value exceeds Number.MAX_SAFE_INTEGER', () => { + const result = extractTimestampMarkup('#[timestamp:99999999999999999999] hello'); + expect(result.cleanText).toBe('hello'); + expect(result.target).toBeUndefined(); + }); + }); + + describe('transformCommentToMessages() with timestamp markup', () => { + test('should strip #[timestamp:...] markup from rendered message text', () => { + const comment = { + created_at: '2024-01-01T00:00:00Z', + created_by: { id: '1', name: 'User', type: 'user' }, + id: 'c1', + message: '#[timestamp:8055,versionId:2390295731268] wowo', + modified_at: '2024-01-01T00:00:00Z', + permissions: { can_delete: true, can_edit: true, can_reply: true, can_resolve: true }, + tagged_message: '', + type: 'comment', + }; + const messages = transformCommentToMessages(comment as unknown as Comment); + expect(messages[0].message.content[0].content).toEqual([{ type: 'text', text: 'wowo' }]); + }); + }); + + describe('transformFeedItem() with enhanced_comment timestamp markup', () => { + test('should populate annotationTarget with frame badge for a comment containing timestamp markup', () => { + const comment = { + created_at: '2024-01-01T00:00:00Z', + created_by: { id: '1', name: 'User', type: 'user' }, + id: 'c1', + message: '#[timestamp:8055,versionId:2390295731268] wowo', + modified_at: '2024-01-01T00:00:00Z', + permissions: {}, + status: 'open', + tagged_message: '', + type: 'comment', + }; + const result = transformFeedItem(comment as unknown as FeedItem); + expect(result!.type).toBe('comment'); + if (result!.type === 'comment') { + expect(result.annotationTarget).toEqual({ timestamp: '0:08', type: 'frame' }); + } + }); + + test('should leave annotationTarget undefined for a regular comment without timestamp markup', () => { + const comment = { + created_at: '2024-01-01T00:00:00Z', + created_by: { id: '1', name: 'User', type: 'user' }, + id: 'c1', + message: 'regular comment', + modified_at: '2024-01-01T00:00:00Z', + permissions: {}, + status: 'open', + tagged_message: '', + type: 'comment', + }; + const result = transformFeedItem(comment as unknown as FeedItem); + if (result!.type === 'comment') { + expect(result.annotationTarget).toBeUndefined(); + } + }); + }); + describe('annotationTargetToBadge()', () => { test('should return undefined when target is missing', () => { expect(annotationTargetToBadge()).toBeUndefined(); diff --git a/src/elements/content-sidebar/activity-feed-v2/transformers.ts b/src/elements/content-sidebar/activity-feed-v2/transformers.ts index 545ac64587..2924027a57 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -42,6 +42,24 @@ import { } from '../../../constants'; const MENTION_REGEX = /@\[(\d+):([^\]]+)\]/g; +const TIMESTAMP_MARKUP_REGEX = /^#\[timestamp:(\d+)(?:,versionId:\d+)?\]\s*/; + +export const extractTimestampMarkup = (text: string): { cleanText: string; target?: AnnotationBadgeTargetType } => { + if (!text) return { cleanText: '' }; + const match = text.match(TIMESTAMP_MARKUP_REGEX); + if (!match) return { cleanText: text }; + + const [fullMatch, timestampMs] = match; + const cleanText = text.slice(fullMatch.length); + const ms = Number(timestampMs); + if (!Number.isSafeInteger(ms) || ms < 0) return { cleanText }; + + const target: AnnotationBadgeTargetType = { + timestamp: convertMillisecondsToTimestamp(ms), + type: AnnotationBadgeType.Frame, + }; + return { cleanText, target }; +}; const parseLine = (line: string, authorId: string): (MentionNode | TextNode)[] => { const nodes: (MentionNode | TextNode)[] = []; @@ -123,14 +141,18 @@ const toPermissions = ( canResolve: permissions?.can_resolve ?? false, }); -const commentToTextMessage = (comment: Comment): TextMessageType => ({ - author: toUserAuthor(comment.created_by), - createdAt: toUnixMs(comment.created_at) ?? 0, - id: comment.id, - message: textToDocumentNode(comment.tagged_message || comment.message || '', comment.created_by?.id ?? ''), - permissions: toPermissions(comment.permissions), - updatedAt: toUpdatedAt(comment.created_at, comment.modified_at), -}); +const commentToTextMessage = (comment: Comment, prebuiltCleanText?: string): TextMessageType => { + const cleanText = + prebuiltCleanText ?? extractTimestampMarkup(comment.tagged_message || comment.message || '').cleanText; + return { + author: toUserAuthor(comment.created_by), + createdAt: toUnixMs(comment.created_at) ?? 0, + id: comment.id, + message: textToDocumentNode(cleanText, comment.created_by?.id ?? ''), + permissions: toPermissions(comment.permissions), + updatedAt: toUpdatedAt(comment.created_at, comment.modified_at), + }; +}; export const transformCommentToMessages = (comment: Comment): TextMessageType[] => { const root = commentToTextMessage(comment); @@ -267,11 +289,16 @@ export const transformFeedItem = (item: FeedItem, currentUserId?: string): Trans case FEED_ITEM_TYPE_COMMENT: { const comment = item as unknown as Comment; const commentIsResolved = comment.status === 'resolved'; + const rawText = comment.tagged_message || comment.message || ''; + const { cleanText, target: annotationTarget } = extractTimestampMarkup(rawText); + const root = commentToTextMessage(comment, cleanText); + const replies = (comment.replies ?? []).map(reply => commentToTextMessage(reply)); return { + annotationTarget, id: comment.id, isResolved: commentIsResolved, - messages: transformCommentToMessages(comment), - originalText: comment.tagged_message || comment.message || '', + messages: [root, ...replies], + originalText: rawText, permissions: comment.permissions ?? {}, resolvedAt: commentIsResolved ? toUnixMs(comment.resolution?.resolved_at) : undefined, resolvedBy: commentIsResolved ? comment.resolution?.resolved_by?.name : undefined, diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index 47449915c5..073ad8a0ab 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -4,7 +4,7 @@ */ import type { AppActivityItemProps, TaskItemProps, VersionItemProps } from '@box/activity-feed'; -import type { TextMessageTypeV2 as TextMessageType } from '@box/threaded-annotations'; +import type { AnnotationBadgeTargetType, TextMessageTypeV2 as TextMessageType } from '@box/threaded-annotations'; import type { Annotation, AnnotationPermission } from '../../../common/types/annotations'; import type { BoxCommentPermission, CommentFeedItemType, FeedItems, FeedItemStatus } from '../../../common/types/feed'; @@ -46,6 +46,7 @@ type ResolvedInfo = { }; export type TransformedCommentItem = { + annotationTarget?: AnnotationBadgeTargetType; id: string; messages: TextMessageType[]; originalText: string;