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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import TaskModal from '../TaskModal';
import FeedItemRow from './FeedItemRow';
import { serializeEditorContent } from './helpers';
import { transformFeedItem } from './transformers';
import { useAvatarUrls } from './useAvatarUrls';
import { useTimeFormat } from './useTimeFormat';
import { useVideoTimestamp } from './useVideoTimestamp';

Expand Down Expand Up @@ -203,16 +204,18 @@ const ActivityFeedV2 = ({
[getTaskCollaborators],
);

const avatarUrls = useAvatarUrls(feedItems, getAvatarUrl);

const transformedItems: TransformedFeedItem[] = React.useMemo(() => {
if (!feedItems) return [];
return feedItems.reduce<TransformedFeedItem[]>((acc, item) => {
const transformed = transformFeedItem(item, currentUserId);
const transformed = transformFeedItem(item, currentUserId, avatarUrls);
if (transformed) {
acc.push(transformed);
}
return acc;
}, []);
}, [currentUserId, feedItems]);
}, [avatarUrls, currentUserId, feedItems]);

const filteredItems = React.useMemo(() => {
const filtered = transformedItems.filter(item => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
const result = transformTaskToProps(mockTask as unknown as TaskNew);
expect(result.assignees).toHaveLength(1);
expect(result.assignees[0]).toEqual({
avatarUrl: 'https://example.com/assignee.png',
avatarUrl: undefined,
completedAt: undefined,
id: '100',
name: 'Assignee One',
Expand All @@ -342,7 +342,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
test('should transform task author from created_by.target', () => {
const result = transformTaskToProps(mockTask as unknown as TaskNew);
expect(result.author).toEqual({
avatarUrl: 'https://example.com/creator.png',
avatarUrl: undefined,
id: '200',
name: 'Creator',
});
Expand Down Expand Up @@ -405,7 +405,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
expect(result.versionNumber).toBe(5);
expect(result.actionType).toBe('upload');
expect(result.authorName).toBe('Uploader');
expect(result.avatarUrl).toBe('https://example.com/uploader.png');
expect(result.avatarUrl).toBeUndefined();
expect(result.createdAt).toBe(new Date('2024-04-01T00:00:00Z').getTime());
});

Expand Down Expand Up @@ -879,4 +879,112 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => {
});
});
});

describe('avatarUrls', () => {
const commentWithoutAvatar = {
created_at: '2024-01-01T00:00:00Z',
created_by: { email: 'u@example.com', id: '42', name: 'No-Avatar User', type: 'user' },
id: 'comment-x',
message: 'hi',
modified_at: '2024-01-01T00:00:00Z',
permissions: { can_delete: false, can_edit: false, can_reply: false, can_resolve: false },
tagged_message: 'hi',
type: 'comment',
};

test('transformCommentToMessages resolves author avatar from the map', () => {
const [message] = transformCommentToMessages(commentWithoutAvatar as unknown as Comment, {
'42': 'fetched://avatar',
});
expect(message.author.avatarUrl).toBe('fetched://avatar');
});

test('transformCommentToMessages leaves avatar undefined when id not in map', () => {
const [message] = transformCommentToMessages(commentWithoutAvatar as unknown as Comment, {
'999': 'other://avatar',
});
expect(message.author.avatarUrl).toBeUndefined();
});

test('transformAnnotationToMessages resolves both root and reply authors from the map', () => {
const annotation = {
created_at: '2024-02-01T00:00:00Z',
created_by: { id: '1', name: 'A', type: 'user' },
description: { message: 'text' },
id: 'a-1',
modified_at: '2024-02-01T00:00:00Z',
permissions: { can_delete: true, can_edit: true, can_reply: true, can_resolve: true },
replies: [
{
created_at: '2024-02-02T00:00:00Z',
created_by: { id: '2', name: 'B', type: 'user' },
id: 'r-1',
message: 'reply',
modified_at: '2024-02-02T00:00:00Z',
permissions: { can_delete: false, can_edit: false, can_reply: false, can_resolve: false },
tagged_message: 'reply',
type: 'comment',
},
],
type: 'annotation',
};
const [root, reply] = transformAnnotationToMessages(annotation as unknown as Annotation, {
'1': 'a://1',
'2': 'a://2',
});
expect(root.author.avatarUrl).toBe('a://1');
expect(reply.author.avatarUrl).toBe('a://2');
});

test('transformTaskToProps resolves author and assignee avatars from the map', () => {
const task = {
assigned_to: {
entries: [
{
id: 'tc-1',
permissions: { can_delete: false, can_update: true },
role: 'ASSIGNEE',
status: 'NOT_STARTED',
target: { id: '100', name: 'Assignee', type: 'user' },
type: 'task_collaborator',
},
],
next_marker: null,
},
completion_rule: 'ANY_ASSIGNEE',
created_at: '2024-03-01T00:00:00Z',
created_by: {
id: 'tc-c',
role: 'CREATOR',
status: 'NOT_STARTED',
target: { id: '200', name: 'Creator', type: 'user' },
type: 'task_collaborator',
},
id: 'task-x',
permissions: { can_delete: false, can_update: false },
status: 'NOT_STARTED',
task_type: 'GENERAL',
type: 'task',
};
const result = transformTaskToProps(task as unknown as TaskNew, undefined, {
'100': 'a://100',
'200': 'a://200',
});
expect(result.author.avatarUrl).toBe('a://200');
expect(result.assignees[0].avatarUrl).toBe('a://100');
});

test('transformVersionToProps resolves the actor avatar from the map', () => {
const version = {
action_type: 'created',
created_at: '2024-04-01T00:00:00Z',
id: 'v-1',
modified_by: { id: '300', name: 'Uploader', type: 'user' },
type: 'file_version',
version_number: '1',
};
const result = transformVersionToProps(version as unknown as BoxItemVersion, { '300': 'a://300' });
expect(result.avatarUrl).toBe('a://300');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { renderHook, waitFor } from '@testing-library/react';
import noop from 'lodash/noop';

import { useAvatarUrls } from '../useAvatarUrls';

import {
FEED_ITEM_TYPE_ANNOTATION,
FEED_ITEM_TYPE_COMMENT,
FEED_ITEM_TYPE_TASK,
FEED_ITEM_TYPE_VERSION,
} from '../../../../constants';

import type { FeedItem } from '../../../../common/types/feed';

const commentItem = (itemId: string, authorId: string, replyAuthorIds: string[] = []): FeedItem =>
({
created_at: '2024-01-01T00:00:00Z',
created_by: { id: authorId, name: 'Author', type: 'user' },
id: itemId,
replies: replyAuthorIds.map(replyAuthorId => ({
created_at: '2024-01-01T00:00:00Z',
created_by: { id: replyAuthorId, name: 'Reply Author', type: 'user' },
id: `${itemId}-reply-${replyAuthorId}`,
type: 'comment',
})),
type: FEED_ITEM_TYPE_COMMENT,
}) as unknown as FeedItem;

const annotationItem = (itemId: string, authorId: string): FeedItem =>
({
created_at: '2024-01-01T00:00:00Z',
created_by: { id: authorId, name: 'Author', type: 'user' },
id: itemId,
type: FEED_ITEM_TYPE_ANNOTATION,
}) as unknown as FeedItem;

const taskItem = (itemId: string, creatorId: string, assigneeIds: string[]): FeedItem =>
({
assigned_to: {
entries: assigneeIds.map(assigneeId => ({
id: `${itemId}-collaborator-${assigneeId}`,
target: { id: assigneeId, name: 'Assignee', type: 'user' },
type: 'task_collaborator',
})),
},
created_by: { target: { id: creatorId, name: 'Creator', type: 'user' } },
id: itemId,
type: FEED_ITEM_TYPE_TASK,
}) as unknown as FeedItem;

const versionItem = (itemId: string, modifierId: string): FeedItem =>
({
id: itemId,
modified_by: { id: modifierId, name: 'Modifier', type: 'user' },
type: FEED_ITEM_TYPE_VERSION,
}) as unknown as FeedItem;

describe('elements/content-sidebar/activity-feed-v2/useAvatarUrls', () => {
test('returns empty map when getAvatarUrl is not provided', () => {
const { result } = renderHook(() => useAvatarUrls([commentItem('comment-1', '1')]));
expect(result.current).toEqual({});
});

test('returns empty map when feedItems is empty', () => {
const getAvatarUrl = jest.fn();
const { result } = renderHook(() => useAvatarUrls([], getAvatarUrl));
expect(result.current).toEqual({});
expect(getAvatarUrl).not.toHaveBeenCalled();
});

test('resolves avatar URLs for unique authors across feed item types', async () => {
const getAvatarUrl = jest.fn(async (id: string) => `url-for-${id}`);
const items: FeedItem[] = [
commentItem('comment-1', '1', ['2']),
annotationItem('annotation-1', '3'),
taskItem('task-1', '4', ['5', '6']),
versionItem('version-1', '7'),
];
const { result } = renderHook(() => useAvatarUrls(items, getAvatarUrl));
await waitFor(() => expect(Object.keys(result.current)).toHaveLength(7));
expect(result.current).toEqual({
'1': 'url-for-1',
'2': 'url-for-2',
'3': 'url-for-3',
'4': 'url-for-4',
'5': 'url-for-5',
'6': 'url-for-6',
'7': 'url-for-7',
});
expect(getAvatarUrl).toHaveBeenCalledTimes(7);
});

test('deduplicates identical user ids across items', async () => {
const getAvatarUrl = jest.fn(async (id: string) => `url-${id}`);
const items: FeedItem[] = [
commentItem('comment-1', '1', ['1']),
commentItem('comment-2', '1'),
taskItem('task-1', '1', ['1']),
];
const { result } = renderHook(() => useAvatarUrls(items, getAvatarUrl));
await waitFor(() => expect(result.current['1']).toBe('url-1'));
expect(getAvatarUrl).toHaveBeenCalledTimes(1);
});

test('does not refetch ids already resolved on subsequent renders', async () => {
const getAvatarUrl = jest.fn(async (id: string) => `url-${id}`);
const initialItems: FeedItem[] = [commentItem('comment-1', '1')];
const { result, rerender } = renderHook(({ items }) => useAvatarUrls(items, getAvatarUrl), {
initialProps: { items: initialItems },
});
await waitFor(() => expect(result.current['1']).toBe('url-1'));
expect(getAvatarUrl).toHaveBeenCalledTimes(1);

rerender({ items: [commentItem('comment-1', '1'), commentItem('comment-2', '2')] });
await waitFor(() => expect(result.current['2']).toBe('url-2'));
expect(getAvatarUrl).toHaveBeenCalledTimes(2);
expect(getAvatarUrl.mock.calls.filter(([userId]) => userId === '1')).toHaveLength(1);
});

test('omits ids whose fetch fails or returns nullish', async () => {
const getAvatarUrl = jest.fn(async (id: string) => {
if (id === '1') throw new Error('boom');
if (id === '2') return null;
return `url-${id}`;
});
const { result } = renderHook(() => useAvatarUrls([commentItem('comment-1', '1', ['2', '3'])], getAvatarUrl));
await waitFor(() => expect(result.current['3']).toBe('url-3'));
expect(result.current['1']).toBeUndefined();
expect(result.current['2']).toBeUndefined();
});

test('retries ids whose previous fetch failed', async () => {
let attempts = 0;
const getAvatarUrl = jest.fn(async (id: string) => {
attempts += 1;
if (id === '1' && attempts === 1) throw new Error('transient');
return `url-${id}`;
});
const items = [commentItem('comment-1', '1')];
const { result, rerender } = renderHook(({ extra }) => useAvatarUrls([...items, ...extra], getAvatarUrl), {
initialProps: { extra: [] as FeedItem[] },
});
await waitFor(() => expect(getAvatarUrl).toHaveBeenCalledTimes(1));
expect(result.current['1']).toBeUndefined();

rerender({ extra: [commentItem('comment-2', '2')] });
await waitFor(() => expect(result.current['1']).toBe('url-1'));
expect(result.current['2']).toBe('url-2');
});

test('refetches in-flight ids after deps change mid-fetch', async () => {
let resolveFirstCall: (value: string) => void = noop;
const firstCallPromise = new Promise<string>(resolve => {
resolveFirstCall = resolve;
});
const getAvatarUrl = jest.fn(async (userId: string) => {
if (userId === '1' && getAvatarUrl.mock.calls.length === 1) return firstCallPromise;
return `url-${userId}`;
});
const initialItems: FeedItem[] = [commentItem('comment-1', '1')];
const { result, rerender } = renderHook(({ items }) => useAvatarUrls(items, getAvatarUrl), {
initialProps: { items: initialItems },
});
await waitFor(() => expect(getAvatarUrl).toHaveBeenCalledTimes(1));

// Dependency change while id '1' is still in flight cancels the original
// promise; the next effect run should reissue the fetch for '1'.
rerender({ items: [commentItem('comment-1', '1'), commentItem('comment-2', '2')] });
await waitFor(() => expect(result.current['2']).toBe('url-2'));

resolveFirstCall('stale');
await waitFor(() => expect(getAvatarUrl.mock.calls.filter(([userId]) => userId === '1')).toHaveLength(2));
expect(result.current['1']).toBe('url-1');
});

test('returns stable result when feedItems is null or undefined', () => {
const getAvatarUrl = jest.fn();
const { result, rerender } = renderHook(
({ items }: { items: FeedItem[] | null | undefined }) => useAvatarUrls(items, getAvatarUrl),
{ initialProps: { items: null } },
);
expect(result.current).toEqual({});
rerender({ items: undefined });
expect(result.current).toEqual({});
expect(getAvatarUrl).not.toHaveBeenCalled();
});
});
Loading
Loading