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
5 changes: 5 additions & 0 deletions src/@types/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface Annotation {
modified_by: User;
permissions: Permissions;
replies?: Array<Reply>;
resolution?: {
resolved_at: string;
resolved_by: User;
} | null;
status?: 'open' | 'resolved';
target: Target;
type: 'annotation';
}
Expand Down
2 changes: 2 additions & 0 deletions src/common/BaseAnnotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ export default class BaseAnnotator extends EventEmitter {
},
common: { color: initialColor, mode: initialMode },
options: {
apiHost,
features,
fileId: file.id,
fileVersionId: fileOptionsVersionId ?? fileVersionId,
isCurrentFileVersion: !fileOptionsVersionId || fileOptionsVersionId === currentFileVersionId,
permissions: file.permissions,
token,
viewMode: initialViewMode,
},
};
Expand Down
130 changes: 124 additions & 6 deletions src/components/Popups/PopupV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
serializeMentionMarkup,
} from '@box/threaded-annotations';
import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations';
import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector';
import type { JSONContent } from '@tiptap/core';
import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';

Expand All @@ -21,6 +22,7 @@ import {
updateAnnotationAction,
} from '../../store/annotations/actions';
import { getAnnotation } from '../../store/annotations/selectors';
import { getApiHost, getToken } from '../../store/options';
import { fetchCollaboratorsAction } from '../../store/users/actions';

import type { AppState, AppThunkDispatch } from '../../store/types';
Expand Down Expand Up @@ -57,17 +59,65 @@ 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> => {
try {
const response = await fetch(`${apiHost}/2.0/users/${userId}/avatar?pic_type=large`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch {
return null;
}
};

const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
const intl = useIntl();
const dispatch = useDispatch<AppThunkDispatch>();
const popupRef = React.useRef<HTMLDivElement>(null);
const popperRef = React.useRef<Instance>();
const optionsRef = React.useRef<Partial<Options>>(getPopupOptions());

const apiHost = useSelector(getApiHost);
const token = useSelector(getToken);
const annotation = useSelector((state: AppState) =>
annotationId ? getAnnotation(state, annotationId) : undefined,
);

const [avatarBlobs, setAvatarBlobs] = React.useState<Record<string, string>>({});
const avatarCacheRef = React.useRef<Map<string, string>>(new Map());
const credentialsRef = React.useRef({ apiHost, token });
credentialsRef.current = { apiHost, token };

const getOrFetchAvatarBlob = React.useCallback(
async (userId: string): Promise<string | null> => {
const cached = avatarCacheRef.current.get(userId);
if (cached) return cached;
const capturedApiHost = apiHost;
const capturedToken = token;
const url = await fetchAvatarBlob(capturedApiHost, capturedToken, userId);
if (!url) return null;
if (
credentialsRef.current.apiHost !== capturedApiHost ||
credentialsRef.current.token !== capturedToken
) {
URL.revokeObjectURL(url);
return null;
}
const existing = avatarCacheRef.current.get(userId);
if (existing) {
URL.revokeObjectURL(url);
return existing;
}
avatarCacheRef.current.set(userId, url);
return url;
},
[apiHost, token],
);

React.useEffect(() => {
if (popupRef.current) {
popperRef.current = createPopper(reference, popupRef.current, optionsRef.current);
Expand All @@ -78,20 +128,85 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
};
}, [reference]);

React.useEffect(() => {
const cache = avatarCacheRef.current;
return () => {
cache.forEach(url => URL.revokeObjectURL(url));
cache.clear();
};
}, []);

React.useEffect(() => {
avatarCacheRef.current.forEach(url => URL.revokeObjectURL(url));
avatarCacheRef.current.clear();
setAvatarBlobs({});
}, [apiHost, token]);

const handleEvent = React.useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);

const threadMessages: TextMessageTypeV2[] = React.useMemo(
() => (annotation ? annotationToMessages(annotation) : []),
[annotation],
);
React.useEffect(() => {
if (!annotation) return undefined;

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

Promise.all(userIds.map(async id => [id, await getOrFetchAvatarBlob(id)] as const)).then(entries => {
if (cancelled) return;
setAvatarBlobs(prev => {
const next = { ...prev };
entries.forEach(([id, url]) => {
if (url) next[id] = url;
});
return next;
});
});

return () => {
cancelled = true;
};
}, [annotation, getOrFetchAvatarBlob]);

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

const isResolved = annotation?.status === 'resolved';
const resolvedBy = isResolved
? annotation?.resolution?.resolved_by?.name ?? annotation?.modified_by?.name
: undefined;
const resolvedAtSource = isResolved
? annotation?.resolution?.resolved_at ?? annotation?.modified_at
: undefined;
const resolvedAt = resolvedAtSource ? new Date(resolvedAtSource).getTime() : undefined;

const userSelectorProps = React.useMemo(
() => ({
allowEmptyQuery: true,
ariaRoleDescription: intl.formatMessage(messages.ariaLabelMentionSelector),
fetchAvatarUrls: async () => ({}),
fetchAvatarUrls: async (userContacts: UserContactType[]): Promise<FetchedAvatarUrls> => {
const urls: FetchedAvatarUrls = {};
await Promise.all(
userContacts.map(async ({ id }) => {
const key = String(id);
const blobUrl = await getOrFetchAvatarBlob(key);
if (blobUrl) {
urls[key] = blobUrl;
}
}),
);
return urls;
},
fetchUsers: async (query: string) => {
const action = await dispatch(fetchCollaboratorsAction(query));
if (fetchCollaboratorsAction.fulfilled.match(action)) {
Expand All @@ -101,7 +216,7 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
},
loadingAriaLabel: intl.formatMessage(messages.ariaLabelMentionLoading),
}),
[dispatch, intl],
[dispatch, getOrFetchAvatarBlob, intl],
);

const handlePost = React.useCallback(
Expand Down Expand Up @@ -169,13 +284,16 @@ const PopupV2 = ({ annotationId, onSubmit, reference }: Props): JSX.Element => {
{annotationId ? (
<ThreadedAnnotationsV2
isAnnotations
isResolved={isResolved}
messages={threadMessages}
onAvatarClick={noop}
onDelete={noop}
onPost={handlePost}
onResolve={handleResolve}
onThreadDelete={handleThreadDelete}
onUnresolve={handleUnresolve}
resolvedAt={resolvedAt}
resolvedBy={resolvedBy}
userSelectorProps={userSelectorProps}
/>
) : (
Expand Down
68 changes: 59 additions & 9 deletions src/components/Popups/__tests__/PopupV2-test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { useDispatch, useSelector } from 'react-redux';
import PopupV2, { Props } from '../PopupV2';
import { getApiHost, getToken } from '../../../store/options';

jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
Expand Down Expand Up @@ -67,8 +68,34 @@ jest.mock('../../../store/users/actions', () => ({
const mockUseDispatch = useDispatch as jest.MockedFunction<typeof useDispatch>;
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;

const mockSelectorValues = (annotation?: unknown): void => {
mockUseSelector.mockImplementation(selector => {
if (selector === getApiHost) return 'https://api.box.com';
if (selector === getToken) return 'test-token';
return annotation;
});
};

describe('PopupV2', () => {
const mockDispatch = jest.fn();
const mockFetch = jest.fn();
const originalFetch = window.fetch;
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;

beforeAll(() => {
window.fetch = mockFetch as unknown as typeof fetch;
window.URL.createObjectURL = jest.fn().mockReturnValue('blob:mock-url');
window.URL.revokeObjectURL = jest.fn();
});

afterAll(() => {
window.fetch = originalFetch;
window.URL.createObjectURL = originalCreateObjectURL;
window.URL.revokeObjectURL = originalRevokeObjectURL;
});

const flushPromises = (): Promise<void> => act(() => new Promise<void>(resolve => { setTimeout(resolve, 0); }));

const mockAnnotation = {
created_at: '2026-01-01T00:00:00Z',
Expand All @@ -92,6 +119,10 @@ describe('PopupV2', () => {

beforeEach(() => {
mockUseDispatch.mockReturnValue(mockDispatch);
mockFetch.mockResolvedValue({
blob: () => Promise.resolve(new Blob(['avatar'])),
ok: true,
});
});

afterEach(() => {
Expand All @@ -105,7 +136,7 @@ describe('PopupV2', () => {
};

beforeEach(() => {
mockUseSelector.mockReturnValue(undefined);
mockSelectorValues(undefined);
});

test('should render MessageEditorV2 with FocusTrap and MentionContextProvider', () => {
Expand Down Expand Up @@ -139,35 +170,39 @@ describe('PopupV2', () => {
};

beforeEach(() => {
mockUseSelector.mockReturnValue(mockAnnotation);
mockSelectorValues(mockAnnotation);
});

test('should render ThreadedAnnotationsV2 with FocusTrap and MentionContextProvider', () => {
test('should render ThreadedAnnotationsV2 with FocusTrap and MentionContextProvider', async () => {
render(<PopupV2 {...defaults} />);
await flushPromises();

expect(screen.getByTestId('focus-trap')).toBeVisible();
expect(screen.getByTestId('mention-context')).toBeVisible();
expect(screen.getByTestId('threaded-annotations-v2')).toBeVisible();
expect(screen.queryByTestId('message-editor-v2')).toBeNull();
});

test('should render with isAnnotations=true and messages from annotation', () => {
test('should render with isAnnotations=true and messages from annotation', async () => {
render(<PopupV2 {...defaults} />);
await flushPromises();

const thread = screen.getByTestId('threaded-annotations-v2');
expect(thread.getAttribute('data-is-annotations')).toBe('true');
expect(thread.getAttribute('data-messages-count')).toBe('1');
});

test('should render empty messages when annotation is not found', () => {
mockUseSelector.mockReturnValue(undefined);
test('should render empty messages when annotation is not found', async () => {
mockSelectorValues(undefined);
render(<PopupV2 {...defaults} />);
await flushPromises();

expect(screen.getByTestId('threaded-annotations-v2').getAttribute('data-messages-count')).toBe('0');
});

test('should pass all action callbacks to ThreadedAnnotationsV2', () => {
test('should pass all action callbacks to ThreadedAnnotationsV2', async () => {
render(<PopupV2 {...defaults} />);
await flushPromises();

const thread = screen.getByTestId('threaded-annotations-v2');
expect(thread.getAttribute('data-has-on-post')).toBe('true');
Expand All @@ -176,21 +211,36 @@ describe('PopupV2', () => {
expect(thread.getAttribute('data-has-on-unresolve')).toBe('true');
});

test('should set popupThreadV2 as resin component', () => {
test('should set popupThreadV2 as resin component', async () => {
render(<PopupV2 {...defaults} />);
await flushPromises();

const popup = screen.getByRole('presentation');
expect(popup).toHaveAttribute('data-resin-component', 'popupThreadV2');
});

test('should fetch avatars with Authorization header and no access_token query param', async () => {
render(<PopupV2 {...defaults} />);
await flushPromises();

expect(mockFetch).toHaveBeenCalledWith(
'https://api.box.com/2.0/users/100/avatar?pic_type=large',
{ headers: { Authorization: 'Bearer test-token' } },
);
const [calledUrl] = mockFetch.mock.calls[0];
expect(calledUrl).not.toContain('access_token');
});
});

test('should set aria-label on popup container', () => {
mockSelectorValues(undefined);
render(<PopupV2 onSubmit={jest.fn()} reference={document.createElement('div')} />);

expect(screen.getByRole('presentation')).toHaveAttribute('aria-label', 'Comment');
});

test('should render portal container for threaded-annotations popovers', () => {
mockSelectorValues(undefined);
render(<PopupV2 onSubmit={jest.fn()} reference={document.createElement('div')} />);

const portal = screen.getByRole('presentation').querySelector('[data-threaded-annotations-portal]');
Expand Down
2 changes: 2 additions & 0 deletions src/store/options/__tests__/selectors-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

describe('store/options/selectors', () => {
const optionsState = {
apiHost: 'https://api.box.com',
features: { enabledFeature: true },
fileId: '12345',
fileVersionId: '67890',
Expand All @@ -21,6 +22,7 @@ describe('store/options/selectors', () => {
},
rotation: 0,
scale: 1,
token: 'test-token',
viewMode: 'annotations' as const,
};

Expand Down
Loading