From 41c379bdb3bc2d1d7c8b71a544c9064a4d8f7292 Mon Sep 17 00:00:00 2001 From: Jackie Jou Date: Wed, 17 Jun 2026 15:49:15 -0700 Subject: [PATCH] fix(activity-feed-v2): preserve timestamp markup when editing video comment Video comments encode the frame timestamp inline as a #[timestamp:NNN,versionId:NNN] markup prefix on the comment message. The transformer strips that prefix for display, but the editor only contains the visible text, so editing the comment dropped the markup from the payload and the badge was lost. Capture the original markup token in extractTimestampMarkup, store it on TransformedCommentItem, and re-prepend it to the serialized text when the root comment is edited. Replies and non-timestamped comments are unchanged. --- .../activity-feed-v2/FeedItemRow.tsx | 5 ++- .../__tests__/FeedItemRow.test.tsx | 38 +++++++++++++++++++ .../__tests__/transformers.test.ts | 4 ++ .../activity-feed-v2/transformers.ts | 17 +++++++-- .../content-sidebar/activity-feed-v2/types.ts | 1 + 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx index cd2af75786..8be0c04ade 100644 --- a/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/FeedItemRow.tsx @@ -118,7 +118,10 @@ const FeedItemRow = ({ const serialized = serializeEditorContent(content); if (!serialized || !serialized.text.trim()) return; if (id === item.id) { - onCommentUpdate?.(id, serialized.text, undefined, serialized.hasMention, permissions); + const text = item.annotationTimestampMarkup + ? `${item.annotationTimestampMarkup} ${serialized.text}` + : serialized.text; + onCommentUpdate?.(id, text, undefined, serialized.hasMention, permissions); return; } dispatchReplyEdit({ diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx b/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx index 5df04af2ef..20a98f948d 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/FeedItemRow.test.tsx @@ -417,6 +417,44 @@ describe('elements/content-sidebar/activity-feed-v2/FeedItemRow', () => { expect(mockedSeekVideoToMs).toHaveBeenCalledWith(8055); }); + + test('should re-prepend timestamp markup when editing a video comment so the badge survives the update', () => { + mockedSerializeEditorContent.mockReturnValue({ hasMention: false, text: 'edited-text' }); + const onCommentUpdate = jest.fn(); + const timestampedComment: TransformedCommentItem = { + ...mockComment, + annotationTarget: { timestamp: '0:08', type: AnnotationBadgeType.Frame }, + annotationTimestampMarkup: '#[timestamp:8055,versionId:2390295731268]', + annotationTimestampMs: 8055, + }; + render(); + + lastThreadedAnnotationProps.onEdit?.('comment-1', { type: 'doc', content: [] }); + + expect(onCommentUpdate).toHaveBeenCalledWith( + 'comment-1', + '#[timestamp:8055,versionId:2390295731268] edited-text', + undefined, + false, + commentPermissions, + ); + }); + + test('should not modify edit text for a regular comment without timestamp markup', () => { + mockedSerializeEditorContent.mockReturnValue({ hasMention: false, text: 'edited-text' }); + const onCommentUpdate = jest.fn(); + render(); + + lastThreadedAnnotationProps.onEdit?.('comment-1', { type: 'doc', content: [] }); + + expect(onCommentUpdate).toHaveBeenCalledWith( + 'comment-1', + 'edited-text', + undefined, + false, + commentPermissions, + ); + }); }); describe('annotation rendering', () => { diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts index c319f685c6..593764b840 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts @@ -689,6 +689,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const result = extractTimestampMarkup('#[timestamp:8055,versionId:2390295731268] wowo'); expect(result.cleanText).toBe('wowo'); expect(result.target).toEqual({ timestamp: '0:08', type: 'frame' }); + expect(result.timestampMarkup).toBe('#[timestamp:8055,versionId:2390295731268]'); expect(result.timestampMs).toBe(8055); }); @@ -696,6 +697,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const result = extractTimestampMarkup('#[timestamp:65000] hello'); expect(result.cleanText).toBe('hello'); expect(result.target).toEqual({ timestamp: '1:05', type: 'frame' }); + expect(result.timestampMarkup).toBe('#[timestamp:65000]'); expect(result.timestampMs).toBe(65000); }); @@ -759,6 +761,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { expect(result!.type).toBe('comment'); if (result!.type === 'comment') { expect(result.annotationTarget).toEqual({ timestamp: '0:08', type: 'frame' }); + expect(result.annotationTimestampMarkup).toBe('#[timestamp:8055,versionId:2390295731268]'); expect(result.annotationTimestampMs).toBe(8055); } }); @@ -778,6 +781,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const result = transformFeedItem(comment as unknown as FeedItem); if (result!.type === 'comment') { expect(result.annotationTarget).toBeUndefined(); + expect(result.annotationTimestampMarkup).toBeUndefined(); expect(result.annotationTimestampMs).toBeUndefined(); } }); diff --git a/src/elements/content-sidebar/activity-feed-v2/transformers.ts b/src/elements/content-sidebar/activity-feed-v2/transformers.ts index 93497bd133..7518ebbd93 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -46,7 +46,12 @@ const TIMESTAMP_MARKUP_REGEX = /^#\[timestamp:(\d+)(?:,versionId:\d+)?\]\s*/; export const extractTimestampMarkup = ( text: string, -): { cleanText: string; target?: AnnotationBadgeTargetType; timestampMs?: number } => { +): { + cleanText: string; + target?: AnnotationBadgeTargetType; + timestampMarkup?: string; + timestampMs?: number; +} => { if (!text) return { cleanText: '' }; const match = text.match(TIMESTAMP_MARKUP_REGEX); if (!match) return { cleanText: text }; @@ -60,7 +65,7 @@ export const extractTimestampMarkup = ( timestamp: convertMillisecondsToTimestamp(ms), type: AnnotationBadgeType.Frame, }; - return { cleanText, target, timestampMs: ms }; + return { cleanText, target, timestampMarkup: fullMatch.trimEnd(), timestampMs: ms }; }; const parseLine = (line: string, authorId: string): (MentionNode | TextNode)[] => { @@ -292,11 +297,17 @@ export const transformFeedItem = (item: FeedItem, currentUserId?: string): Trans const comment = item as unknown as Comment; const commentIsResolved = comment.status === 'resolved'; const rawText = comment.tagged_message || comment.message || ''; - const { cleanText, target: annotationTarget, timestampMs } = extractTimestampMarkup(rawText); + const { + cleanText, + target: annotationTarget, + timestampMarkup, + timestampMs, + } = extractTimestampMarkup(rawText); const root = commentToTextMessage(comment, cleanText); const replies = (comment.replies ?? []).map(reply => commentToTextMessage(reply)); return { annotationTarget, + annotationTimestampMarkup: timestampMarkup, annotationTimestampMs: timestampMs, id: comment.id, isResolved: commentIsResolved, diff --git a/src/elements/content-sidebar/activity-feed-v2/types.ts b/src/elements/content-sidebar/activity-feed-v2/types.ts index 9710a5ad2b..75aa4757af 100644 --- a/src/elements/content-sidebar/activity-feed-v2/types.ts +++ b/src/elements/content-sidebar/activity-feed-v2/types.ts @@ -47,6 +47,7 @@ type ResolvedInfo = { export type TransformedCommentItem = { annotationTarget?: AnnotationBadgeTargetType; + annotationTimestampMarkup?: string; annotationTimestampMs?: number; id: string; messages: TextMessageType[];