Skip to content
Draft
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
5 changes: 3 additions & 2 deletions src/@types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export interface Permissions {
}

export type TokenLiteral = null | undefined | string | { read: string; write?: string };
export type TokenResolver = () => TokenLiteral | Promise<TokenLiteral>;
export type Token = TokenLiteral | TokenResolver;
export type TokenMap = { [typedFileId: string]: TokenLiteral };
export type TokenResolver = (typedFileId?: string) => TokenLiteral | TokenMap | Promise<TokenLiteral | TokenMap>;
export type Token = TokenLiteral | TokenMap | TokenResolver;
5 changes: 5 additions & 0 deletions src/@types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ enum Event {
CREATOR_STAGED_CHANGE = 'creator_staged_change',
CREATOR_STATUS_CHANGE = 'creator_status_change',
ANNOTATION_CREATE = 'annotations_create',
ANNOTATION_DELETE = 'annotations_delete',
ANNOTATION_FETCH_ERROR = 'annotations_fetch_error',
ANNOTATION_REMOVE = 'annotations_remove',
ANNOTATION_REPLY_CREATE = 'annotations_reply_create',
ANNOTATION_REPLY_DELETE = 'annotations_reply_delete',
ANNOTATION_REPLY_UPDATE = 'annotations_reply_update',
ANNOTATION_UPDATE = 'annotations_update',
ANNOTATIONS_INITIALIZED = 'annotations_initialized',
ANNOTATIONS_MODE_CHANGE = 'annotations_mode_change',
COLOR_SET = 'annotations_color_set',
Expand Down
1 change: 1 addition & 0 deletions src/@types/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface Reply {
created_by: User;
id: string;
message: string;
modified_at?: string;
parent: {
id: string;
type: string;
Expand Down
37 changes: 36 additions & 1 deletion src/adapters/__tests__/threadedAnnotationsAdapters-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,55 @@ describe('threadedAnnotationsAdapters', () => {
});
});

test('should map reply permissions from backend payload, forcing canEdit false', () => {
test('should map reply permissions from backend payload', () => {
const reply: Reply = {
...mockReply,
permissions: { can_delete: true, can_edit: true, can_reply: true, can_resolve: true },
};
const result = replyToTextMessage(reply);

expect(result.permissions).toEqual({
canDelete: true,
canEdit: true,
canReply: true,
canResolve: true,
});
});

test('should default canEdit to false when backend payload omits it', () => {
const reply: Reply = {
...mockReply,
permissions: { can_delete: true, can_reply: true, can_resolve: true },
};
const result = replyToTextMessage(reply);

expect(result.permissions).toEqual({
canDelete: true,
canEdit: false,
canReply: true,
canResolve: true,
});
});

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

expect(result.updatedAt).toBeUndefined();
});

test('should leave updatedAt undefined when modified_at equals created_at', () => {
const reply: Reply = { ...mockReply, modified_at: mockReply.created_at };
const result = replyToTextMessage(reply);

expect(result.updatedAt).toBeUndefined();
});

test('should set updatedAt to modified_at instant when reply was edited', () => {
const reply: Reply = { ...mockReply, modified_at: '2026-03-15T11:00:00Z' };
const result = replyToTextMessage(reply);

expect(result.updatedAt).toBe(new Date('2026-03-15T11:00:00Z').getTime());
});
});

describe('annotationToMessages', () => {
Expand Down
30 changes: 16 additions & 14 deletions src/adapters/threadedAnnotationsAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => {
return { type: 'doc', content };
};

/**
* 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 | undefined): number | undefined => {
if (!modifiedAt) return 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;
};

/**
* Converts a box-annotations Reply to a threaded-annotations TextMessageType.
*/
Expand All @@ -83,25 +97,13 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({
message: deserializeMentionMarkup(reply.message),
permissions: {
canDelete: reply.permissions?.can_delete ?? false,
canEdit: false,
canEdit: reply.permissions?.can_edit ?? false,
canReply: reply.permissions?.can_reply ?? false,
canResolve: reply.permissions?.can_resolve ?? false,
},
updatedAt: toUpdatedAt(reply.created_at, reply.modified_at),
});

/**
* 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 => ({
Expand Down
7 changes: 6 additions & 1 deletion src/api/APIFactory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Annotations from 'box-ui-elements/es/api/Annotations';
import FileCollaborators from 'box-ui-elements/es/api/FileCollaborators';
import ThreadedComments from 'box-ui-elements/es/api/ThreadedComments';
import { DEFAULT_HOSTNAME_API } from 'box-ui-elements/es/constants';
import { AnnotationsAPI, CollaboratorsAPI, APIOptions } from './types';
import { AnnotationsAPI, CollaboratorsAPI, APIOptions, ThreadedCommentsAPI } from './types';

export default class APIFactory {
options: APIOptions;
Expand All @@ -21,4 +22,8 @@ export default class APIFactory {
getCollaboratorsAPI(): CollaboratorsAPI {
return new FileCollaborators(this.options);
}

getThreadedCommentsAPI(): ThreadedCommentsAPI {
return new ThreadedComments(this.options);
}
}
5 changes: 5 additions & 0 deletions src/api/__mocks__/APIFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ export default jest.fn(() => ({
),
destroy: jest.fn(),
})),
getThreadedCommentsAPI: jest.fn(() => ({
deleteComment: jest.fn(({ successCallback }) => successCallback()),
updateComment: jest.fn(({ successCallback }) => successCallback({ id: 'reply_1', message: 'updated' })),
destroy: jest.fn(),
})),
}));
24 changes: 23 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, Token } from '../@types';
import { Annotation, Collaborator, NewAnnotation, Permissions, Reply, ReplyPermissions, Token } from '../@types';

export type APICollection<R> = {
entries: R[];
Expand Down Expand Up @@ -73,6 +73,28 @@ export interface AnnotationsAPI {
destroy(): void;
}

export interface ThreadedCommentsAPI {
deleteComment(args: {
commentId: string;
errorCallback: (error: APIError) => void;
fileId: string | null;
permissions: ReplyPermissions;
successCallback: () => void;
}): void;

updateComment(args: {
commentId: string;
errorCallback: (error: APIError) => void;
fileId: string | null;
message?: string;
permissions: ReplyPermissions;
status?: string;
successCallback: (reply: Reply) => void;
}): void;

destroy(): void;
}

export interface CollaboratorsAPI {
getFileCollaborators(
fileId: string | null,
Expand Down
4 changes: 2 additions & 2 deletions src/common/BaseAnnotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import DeselectManager from './DeselectManager';
import EventEmitter from './EventEmitter';
import i18n from '../utils/i18n';
import messages from '../messages';
import { Event, IntlOptions, LegacyEvent, Permissions } from '../@types';
import { Event, IntlOptions, LegacyEvent, Permissions, Token } from '../@types';
import { BoundingBox, getBoundingBoxHighlights } from '../store/boundingBoxHighlights';
import { ViewMode } from '../store/options/types';
import { Features } from '../BoxAnnotations';
Expand Down Expand Up @@ -45,7 +45,7 @@ export type Options = {
intl: IntlOptions;
locale?: string;
onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void;
token: string;
token: Token;
};

export const CSS_CONTAINER_CLASS = 'ba';
Expand Down
56 changes: 46 additions & 10 deletions src/components/Popups/PopupV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext'
import {
createReplyAction,
deleteAnnotationAction,
deleteReplyAction,
setActiveAnnotationIdAction,
updateAnnotationAction,
updateReplyAction,
} from '../../store/annotations/actions';
import { getAnnotation } from '../../store/annotations/selectors';
import { getApiHost, getFileVersionId, getToken } from '../../store/options';
import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options';
import { fetchCollaboratorsAction } from '../../store/users/actions';

import type { Token, TokenLiteral, TokenMap } from '../../@types';
import type { AppState, AppThunkDispatch } from '../../store/types';

import createPopper, { PopupReference } from './Popper';
Expand Down Expand Up @@ -63,12 +66,32 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => {
return { type: 'doc', content: [content] } as DocumentNodeV2;
};

// Callers render initials as a fallback on null.
// A persistent null across all users usually indicates a stale token.
const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): Promise<string | null> => {
const literalToString = (literal: TokenLiteral): string | null => {
if (!literal) return null;
if (typeof literal === 'string') return literal;
return literal.read ?? literal.write ?? null;
};

const resolveStringToken = async (token: Token, typedFileId: string): Promise<string | null> => {
const resolved = typeof token === 'function' ? await token(typedFileId) : token;
if (!resolved) return null;
if (typeof resolved === 'string') return resolved;
if ('read' in resolved) return literalToString(resolved as TokenLiteral);
return literalToString((resolved as TokenMap)[typedFileId]);
};

const fetchAvatarBlob = async (
apiHost: string,
token: Token,
fileId: string | null,
userId: string,
): Promise<string | null> => {
try {
if (!fileId) return null;
const stringToken = await resolveStringToken(token, `file_${fileId}`);
if (!stringToken) return null;
const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
headers: { Authorization: `Bearer ${token}` },
headers: { Authorization: `Bearer ${stringToken}` },
});
if (!response.ok) return null;
const blob = await response.blob();
Expand All @@ -87,6 +110,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());

const apiHost = useSelector(getApiHost);
const fileId = useSelector(getFileId);
const fileVersionId = useSelector(getFileVersionId);
const token = useSelector(getToken);
const onCopyLink = React.useMemo(
Expand All @@ -111,7 +135,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
if (cached) return cached;
const capturedApiHost = apiHost;
const capturedToken = token;
const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
const url = await fetchAvatarBlob(capturedApiHost, capturedToken, fileId, userId);
if (!url) return null;
if (
credentialsRef.current.apiHost !== capturedApiHost ||
Expand All @@ -128,7 +152,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
avatarCacheRef.current.set(userId, url);
return url;
},
[apiHost, token],
[apiHost, fileId, token],
);

React.useEffect(() => {
Expand Down Expand Up @@ -247,10 +271,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J

const handleEdit = React.useCallback(
async (id: string, content: JSONContent | null): Promise<void> => {
if (!annotationId || id !== annotationId) return;
if (!annotationId) return;
const doc = createDocumentNode(content);
const { text } = serializeMentionMarkup(doc);
await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
if (id === annotationId) {
await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } }));
return;
}
await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } }));
},
[annotationId, dispatch],
);
Expand All @@ -264,6 +292,14 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
[annotationId, dispatch],
);

const handleDelete = React.useCallback(
async (id: string): Promise<void> => {
if (!annotationId || id === annotationId) return;
await dispatch(deleteReplyAction({ annotationId, replyId: id }));
},
[annotationId, dispatch],
);

const handleResolve = React.useCallback(
async (): Promise<void> => {
if (!annotationId) return;
Expand Down Expand Up @@ -314,7 +350,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J
messages={threadMessages}
onAvatarClick={noop}
onCopyLink={onCopyLink}
onDelete={noop}
onDelete={handleDelete}
onEdit={handleEdit}
onPost={handlePost}
onResolve={handleResolve}
Expand Down
Loading