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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@box/item-icon": "^3.2.0",
"@box/languages": "^1.1.0",
"@box/readable-time": "^2.1.32",
"@box/threaded-annotations": "^4.0.6",
"@box/threaded-annotations": "^4.11.0",
"@box/types": "^2.1.8",
"@box/user-selector": "^2.1.35",
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
Expand Down Expand Up @@ -138,7 +138,7 @@
"@box/combobox-with-api": "^1.42.22",
"@box/item-icon": "^3.2.0",
"@box/readable-time": "^2.1.32",
"@box/threaded-annotations": "^4.0.6",
"@box/threaded-annotations": "^4.11.0",
"@box/types": "^2.1.8",
"@box/user-selector": "^2.1.35"
},
Expand Down
1 change: 1 addition & 0 deletions src/BoxAnnotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type AnnotationsOptions = {
};

export type Features = {
isRichTextEnabled?: boolean;
isThreadedAnnotation?: boolean;
[key: string]: boolean | undefined;
};
Expand Down
52 changes: 52 additions & 0 deletions src/adapters/__tests__/threadedAnnotationsAdapters-test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseMessageMarkdown } from '@box/threaded-annotations';

import {
annotationToMessages,
collaboratorToUserContact,
Expand All @@ -7,6 +9,12 @@ import {
import type { Annotation, Collaborator, Reply } from '../../@types';
import { TARGET_TYPE } from '../../constants';

jest.mock('@box/threaded-annotations', () => ({
parseMessageMarkdown: jest.fn((text: string | null | undefined) =>
text ? { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] } : { type: 'doc', content: [] },
),
}));

describe('threadedAnnotationsAdapters', () => {
describe('deserializeMentionMarkup', () => {
test('should return empty doc for empty string', () => {
Expand Down Expand Up @@ -109,6 +117,10 @@ describe('threadedAnnotationsAdapters', () => {
type: 'reply',
};

beforeEach(() => {
(parseMessageMarkdown as jest.Mock).mockClear();
});

test('should map reply fields to TextMessageType', () => {
const result = replyToTextMessage(mockReply);

Expand All @@ -126,6 +138,7 @@ describe('threadedAnnotationsAdapters', () => {
};
const result = replyToTextMessage(reply);

expect(parseMessageMarkdown).not.toHaveBeenCalled();
expect(result.message.content[0].content).toHaveLength(3);
expect(result.message.content[0].content?.[1]).toMatchObject({
type: 'mention',
Expand Down Expand Up @@ -174,6 +187,18 @@ describe('threadedAnnotationsAdapters', () => {
});
});

test('should parse markdown when isRichTextEnabled is true', () => {
const reply: Reply = { ...mockReply, message: '**bold**' };

const result = replyToTextMessage(reply, true);

expect(parseMessageMarkdown).toHaveBeenCalledWith('**bold**');
expect(result.message).toEqual({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '**bold**' }] }],
});
});

test('should leave updatedAt undefined when reply has no modified_at', () => {
const result = replyToTextMessage(mockReply);

Expand Down Expand Up @@ -207,6 +232,10 @@ describe('threadedAnnotationsAdapters', () => {
type: 'annotation',
};

beforeEach(() => {
(parseMessageMarkdown as jest.Mock).mockClear();
});

test('should return empty array when no description or replies', () => {
const result = annotationToMessages(baseAnnotation);
expect(result).toEqual([]);
Expand Down Expand Up @@ -268,6 +297,29 @@ describe('threadedAnnotationsAdapters', () => {
expect(result[0].author.name).toBe('User');
expect(result[1].author.name).toBe('Other');
});

test('should parse description and replies with parseMessageMarkdown when enabled', () => {
const annotation: Annotation = {
...baseAnnotation,
description: { message: 'Root' } as unknown as Reply,
replies: [
{
created_at: '2026-01-02T00:00:00Z',
created_by: { id: '2', login: 'other@box.com', name: 'Other', type: 'user' },
id: 'reply-1',
message: 'First reply',
parent: { id: 'ann-1', type: 'annotation' },
type: 'reply',
},
],
};

annotationToMessages(annotation, true);

expect(parseMessageMarkdown).toHaveBeenCalledWith('Root');
expect(parseMessageMarkdown).toHaveBeenCalledWith('First reply');
expect(parseMessageMarkdown).toHaveBeenCalledTimes(2);
});
});

describe('collaboratorToUserContact', () => {
Expand Down
18 changes: 11 additions & 7 deletions src/adapters/threadedAnnotationsAdapters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DocumentNodeV2, MentionNodeV2, ParagraphNodeV2, TextMessageTypeV2, TextNodeV2 } from '@box/threaded-annotations';
import { parseMessageMarkdown } from '@box/threaded-annotations';

import type { Annotation, Collaborator, Reply, UserMini } from '../@types';

Expand Down Expand Up @@ -69,6 +70,9 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => {
return { type: 'doc', content };
};

const toDocumentNode = (text: string, isRichTextEnabled = false): DocumentNodeV2 =>
isRichTextEnabled ? (parseMessageMarkdown(text) as DocumentNodeV2) : deserializeMentionMarkup(text);

/**
* Returns the edit timestamp consumers use to render an edited indicator.
* Compares parsed instants, not raw strings, so equivalent ISO formats
Expand All @@ -86,15 +90,15 @@ const toUpdatedAt = (createdAt: string, modifiedAt: string | undefined): number
/**
* Converts a box-annotations Reply to a threaded-annotations TextMessageType.
*/
export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
export const replyToTextMessage = (reply: Reply, isRichTextEnabled = false): TextMessageTypeV2 => ({
author: {
email: reply.created_by?.login ?? '',
id: parseInt(reply.created_by?.id ?? '0', 10),
name: reply.created_by?.name ?? '',
},
createdAt: new Date(reply.created_at).getTime(),
id: reply.id,
message: deserializeMentionMarkup(reply.message),
message: toDocumentNode(reply.message, isRichTextEnabled),
permissions: {
canDelete: reply.permissions?.can_delete ?? false,
canEdit: reply.permissions?.can_edit ?? false,
Expand All @@ -106,15 +110,15 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({

// 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 => ({
const descriptionToTextMessage = (annotation: Annotation, isRichTextEnabled = false): TextMessageTypeV2 => ({
author: {
email: annotation.created_by?.login ?? '',
id: parseInt(annotation.created_by?.id ?? '0', 10),
name: annotation.created_by?.name ?? '',
},
createdAt: new Date(annotation.created_at).getTime(),
id: annotation.id,
message: deserializeMentionMarkup(annotation.description?.message ?? ''),
message: toDocumentNode(annotation.description?.message ?? '', isRichTextEnabled),
permissions: {
canDelete: annotation.permissions?.can_delete ?? false,
canEdit: annotation.permissions?.can_edit ?? false,
Expand All @@ -128,16 +132,16 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 =>
* Maps a full Annotation (description + replies) to an array of TextMessageType
* suitable for ThreadedAnnotationsV2.
*/
export const annotationToMessages = (annotation: Annotation): TextMessageTypeV2[] => {
export const annotationToMessages = (annotation: Annotation, isRichTextEnabled = false): TextMessageTypeV2[] => {
const messages: TextMessageTypeV2[] = [];

if (annotation.description) {
messages.push(descriptionToTextMessage(annotation));
messages.push(descriptionToTextMessage(annotation, isRichTextEnabled));
}

if (annotation.replies) {
annotation.replies.forEach(reply => {
messages.push(replyToTextMessage(reply));
messages.push(replyToTextMessage(reply, isRichTextEnabled));
});
}

Expand Down
25 changes: 16 additions & 9 deletions src/components/Popups/PopupV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MessageEditorV2,
ThreadedAnnotationsV2,
serializeMentionMarkup,
serializeMessageToMarkdown,
} from '@box/threaded-annotations';
import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
Expand All @@ -27,7 +28,7 @@ import {
updateReplyAction,
} from '../../store/annotations/actions';
import { getAnnotation } from '../../store/annotations/selectors';
import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options';
import { getApiHost, getFileId, getFileVersionId, getToken, isFeatureEnabled } from '../../store/options';
import { fetchCollaboratorsAction } from '../../store/users/actions';

import type { Token, TokenLiteral, TokenMap } from '../../@types';
Expand Down Expand Up @@ -66,6 +67,9 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
return { type: 'doc', content: [content] } as DocumentNodeV2;
};

const serializePopupMessage = (doc: DocumentNodeV2, isRichTextEnabled: boolean): string =>
isRichTextEnabled ? serializeMessageToMarkdown(doc) : serializeMentionMarkup(doc).text;

const literalToString = (literal: TokenLiteral): string | null => {
if (!literal) return null;
if (typeof literal === 'string') return literal;
Expand Down Expand Up @@ -112,6 +116,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
const apiHost = useSelector(getApiHost);
const fileId = useSelector(getFileId);
const fileVersionId = useSelector(getFileVersionId);
const isRichTextEnabled = useSelector((state: AppState) => isFeatureEnabled(state, 'isRichTextEnabled'));
const token = useSelector(getToken);
const onCopyLink = React.useMemo(
() =>
Expand Down Expand Up @@ -187,7 +192,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
if (!annotation) return undefined;

const userIds = Array.from(
new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))),
new Set(annotationToMessages(annotation, isRichTextEnabled).map(msg => String(msg.author.id))),
);
let cancelled = false;

Expand All @@ -205,18 +210,18 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
return () => {
cancelled = true;
};
}, [annotation, getOrFetchAvatarBlob]);
}, [annotation, getOrFetchAvatarBlob, isRichTextEnabled]);

const threadMessages: TextMessageTypeV2[] = React.useMemo(() => {
if (!annotation) return [];
return annotationToMessages(annotation).map(msg => ({
return annotationToMessages(annotation, isRichTextEnabled).map(msg => ({
...msg,
author: {
...msg.author,
avatarUrl: avatarBlobs[String(msg.author.id)],
},
}));
}, [annotation, avatarBlobs]);
}, [annotation, avatarBlobs, isRichTextEnabled]);

const isResolved = annotation?.status === 'resolved';
const resolvedBy = isResolved
Expand Down Expand Up @@ -259,28 +264,28 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
const handlePost = React.useCallback(
async (content: JSONContent | null): Promise<void> => {
const doc = createDocumentNode(content);
const { text } = serializeMentionMarkup(doc);
const text = serializePopupMessage(doc, isRichTextEnabled);
if (annotationId) {
await dispatch(createReplyAction({ annotationId, message: text }));
} else {
onSubmit(text);
}
},
[annotationId, dispatch, onSubmit],
[annotationId, dispatch, isRichTextEnabled, onSubmit],
);

const handleEdit = React.useCallback(
async (id: string, content: JSONContent | null): Promise<void> => {
if (!annotationId) return;
const doc = createDocumentNode(content);
const { text } = serializeMentionMarkup(doc);
const text = serializePopupMessage(doc, isRichTextEnabled);
if (id === annotationId) {
await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
return;
}
await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } }));
},
[annotationId, dispatch],
[annotationId, dispatch, isRichTextEnabled],
);

const handleThreadDelete = React.useCallback(
Expand Down Expand Up @@ -349,6 +354,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
<ThreadedAnnotationsV2
isAnnotations
isResolved={isResolved}
isRichTextEnabled={isRichTextEnabled}
messages={threadMessages}
onAvatarClick={noop}
onCopyLink={onCopyLink}
Expand All @@ -365,6 +371,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
) : (
<MessageEditorV2
isFirstAnnotation
isRichTextEnabled={isRichTextEnabled}
onPost={handlePost}
userSelectorProps={userSelectorProps}
/>
Expand Down
Loading