From 8d41cfb9b041081b9186d23d20d41b2114044ae9 Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Tue, 19 May 2026 20:03:23 -0700 Subject: [PATCH] fix(popup): map edited timestamp to threaded-annotations updatedAt Populate updatedAt on the root description message so PopupV2 surfaces the edited indicator after the user edits an annotation. The thread component reads updatedAt to decide whether to render the indicator and expects it undefined for unedited messages. Compare parsed instants instead of raw ISO strings so equivalent formats (Z vs +00:00, fractional precision) are treated as unedited. Unparseable modified_at falls back to undefined; the consumer hides the indicator rather than crashing the popup. --- .../threadedAnnotationsAdapters-test.ts | 17 ++++++++++++++++- src/adapters/threadedAnnotationsAdapters.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts index 08cc0a047..44607d2c4 100644 --- a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts +++ b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts @@ -194,6 +194,22 @@ describe('threadedAnnotationsAdapters', () => { expect(result[0].permissions.canEdit).toBe(true); }); + test.each([ + ['identical timestamps', '2026-01-01T00:00:00Z', undefined], + ['equivalent ISO formats with fractional precision', '2026-01-01T00:00:00.000Z', undefined], + ['unparseable modified_at', 'not-a-date', undefined], + ['edited later', '2026-02-01T00:00:00Z', new Date('2026-02-01T00:00:00Z').getTime()], + ])('should map updatedAt for %s', (_label, modifiedAt, expected) => { + const annotation: Annotation = { + ...baseAnnotation, + description: { message: 'Root' } as unknown as Reply, + modified_at: modifiedAt, + }; + const result = annotationToMessages(annotation); + + expect(result[0].updatedAt).toBe(expected); + }); + test('should include description and replies in order', () => { const annotation: Annotation = { ...baseAnnotation, @@ -217,7 +233,6 @@ describe('threadedAnnotationsAdapters', () => { expect(result[0].author.name).toBe('User'); expect(result[1].author.name).toBe('Other'); }); - }); describe('collaboratorToUserContact', () => { diff --git a/src/adapters/threadedAnnotationsAdapters.ts b/src/adapters/threadedAnnotationsAdapters.ts index 47e3a0434..0029056ab 100644 --- a/src/adapters/threadedAnnotationsAdapters.ts +++ b/src/adapters/threadedAnnotationsAdapters.ts @@ -89,6 +89,19 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({ }, }); +/** + * Returns the edit timestamp consumers use to render an edited indicator. + * Compares parsed instants, not raw strings, so equivalent ISO formats + * (Z vs +00:00, fractional precision) are treated as unedited. + */ +const toUpdatedAt = (createdAt: string, modifiedAt: string): number | undefined => { + const modifiedMs = new Date(modifiedAt).getTime(); + if (Number.isNaN(modifiedMs)) return undefined; + const createdMs = new Date(createdAt).getTime(); + if (modifiedMs === createdMs) return undefined; + return modifiedMs; +}; + // The root message shares the annotation's author and permissions; description // comes back sparse ({ message } only) from the list endpoint. const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => ({ @@ -106,6 +119,7 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => canReply: annotation.permissions?.can_reply ?? false, canResolve: annotation.permissions?.can_resolve ?? false, }, + updatedAt: toUpdatedAt(annotation.created_at, annotation.modified_at), }); /**