Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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;
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);
Comment thread
jackiejou marked this conversation as resolved.
Outdated
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