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;