Skip to content

Commit

Permalink
feat: Improve thread metrics (#32906)
Browse files Browse the repository at this point in the history
  • Loading branch information
gabriellsh authored Nov 19, 2024
1 parent 8f8e413 commit 66ecc64
Show file tree
Hide file tree
Showing 23 changed files with 683 additions and 161 deletions.
6 changes: 6 additions & 0 deletions .changeset/lazy-avocados-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---

Improves thread metrics featuring user avatars, better titles and repositioned elements.
4 changes: 2 additions & 2 deletions apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Message } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models';
import {
isChatReportMessageProps,
Expand Down Expand Up @@ -550,7 +550,7 @@ API.v1.addRoute(
};

const threadQuery = { ...query, ...typeThread, rid: room._id, tcount: { $exists: true } };
const { cursor, totalCount } = await Messages.findPaginated(threadQuery, {
const { cursor, totalCount } = await Messages.findPaginated<IThreadMainMessage>(threadQuery, {
sort: sort || { tlm: -1 },
skip: offset,
limit: count,
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/app/utils/server/lib/normalizeMessagesForUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Users } from '@rocket.chat/models';

import { settings } from '../../../settings/server';

const filterStarred = (message: IMessage, uid?: string): IMessage => {
const filterStarred = <T extends IMessage = IMessage>(message: T, uid?: string): T => {
// if Allow_anonymous_read is enabled, uid will be undefined
if (!uid) return message;

Expand All @@ -20,7 +20,7 @@ function getNameOfUsername(users: Map<string, string>, username: string): string
return users.get(username) || username;
}

export const normalizeMessagesForUser = async (messages: IMessage[], uid?: string): Promise<IMessage[]> => {
export const normalizeMessagesForUser = async <T extends IMessage = IMessage>(messages: T[], uid?: string): Promise<T[]> => {
// if not using real names, there is nothing else to do
if (!settings.get('UI_Use_Real_Name')) {
return messages.map((message) => filterStarred(message, uid));
Expand Down
4 changes: 1 addition & 3 deletions apps/meteor/client/components/UserInfo/UserInfoAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';

const UserInfoAvatar = ({ username, ...props }: ComponentProps<typeof UserAvatar>): ReactElement => (
<UserAvatar title={username} username={username} size='x332' {...props} />
);
const UserInfoAvatar = (props: ComponentProps<typeof UserAvatar>): ReactElement => <UserAvatar size='x332' {...props} />;

export default UserInfoAvatar;
326 changes: 326 additions & 0 deletions apps/meteor/client/components/message/content/ThreadMetrics.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import { mockAppRoot, MockedRouterContext } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import React from 'react';

import ThreadMetrics from './ThreadMetrics';
import ThreadMetricsFollow from './ThreadMetricsFollow';
import ThreadMetricsParticipants from './ThreadMetricsParticipants';

const toggleFollowMock =
(done: jest.DoneCallback | (() => undefined)) =>
({ mid }: { mid: string }) => {
expect(mid).toBe('mid');
done();
return null;
};

global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

const mockRoot = () => {
const AppRoot = mockAppRoot();
const buildWithRouter = (navigate: (...args: any[]) => void) => {
const Wrapper = AppRoot.build();
return function Mock({ children }: { children: ReactNode }) {
return (
<Wrapper>
<MockedRouterContext router={{ navigate, getRouteName: () => 'thread' as any }}>{children}</MockedRouterContext>
</Wrapper>
);
};
};

return Object.assign(AppRoot, { buildWithRouter });
};

const mockedTranslations = [
'en',
'core',
{
Follower_one: 'follower',
Follower_other: 'followers',
__count__replies__date__: '{{count}} replies {{date}}',
__count__replies: '{{count}} replies',
},
] as const;

let inlineSize = 400;
jest.mock('@rocket.chat/fuselage-hooks', () => {
const originalModule = jest.requireActual('@rocket.chat/fuselage-hooks');
return {
...originalModule,
useResizeObserver: () => ({ ref: () => undefined, borderBoxSize: { inlineSize } }),
};
});

describe('Thread Metrics', () => {
describe('Main component', () => {
it('should render large followed with 3 participants and unread', async () => {
const navigateSpy = jest.fn();
const navigateCallback = (route: any) => {
navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context);
};

render(
<ThreadMetrics
lm={new Date(2024, 6, 1, 0, 0, 0)}
counter={5}
participants={['user1', 'user2', 'user3']}
unread={true}
mention={false}
all={false}
mid='mid'
rid='rid'
following={true}
/>,
{
wrapper: mockRoot()
.withEndpoint(
'POST',
'/v1/chat.followMessage',
toggleFollowMock(() => undefined),
)
.withEndpoint(
'POST',
'/v1/chat.unfollowMessage',
toggleFollowMock(() => undefined),
)
.withUserPreference('clockMode', 1)
.withSetting('Message_TimeFormat', 'LT')
.withTranslations(...mockedTranslations)
.buildWithRouter(navigateCallback),
legacyRoot: true,
},
);

const followButton = screen.getByTitle('Following');
expect(followButton).toBeVisible();

const badge = screen.getByTitle('Unread');
expect(badge).toBeVisible();

expect(screen.getByTitle('followers')).toBeVisible();
expect(screen.getByText('3')).toBeVisible();

const replyButton = screen.getByText('View_thread');
expect(replyButton).toBeVisible();
await userEvent.click(replyButton);

expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid');

const threadCount = screen.getByTitle('Last_message__date__');
expect(threadCount).toHaveTextContent('5 replies July 1, 2024');
});

it('should render small not followed with 3 participants and unread', async () => {
const navigateSpy = jest.fn();
const navigateCallback = (route: any) => {
navigateSpy(route.name, route.params.rid, route.params.tab, route.params.context);
};
inlineSize = 200;

render(
<ThreadMetrics
lm={new Date(2024, 6, 1, 0, 0, 0)}
counter={5}
participants={['user1', 'user2', 'user3']}
unread={true}
mention={false}
all={false}
mid='mid'
rid='rid'
following={false}
/>,
{
wrapper: mockRoot()
.withEndpoint(
'POST',
'/v1/chat.followMessage',
toggleFollowMock(() => undefined),
)
.withEndpoint(
'POST',
'/v1/chat.unfollowMessage',
toggleFollowMock(() => undefined),
)
.withUserPreference('clockMode', 1)
.withSetting('Message_TimeFormat', 'LT')
.withTranslations(...mockedTranslations)
.buildWithRouter(navigateCallback),
legacyRoot: true,
},
);
const followButton = screen.getByTitle('Not_following');
expect(followButton).toBeVisible();

const badge = screen.getByTitle('Unread');
expect(badge).toBeVisible();

expect(screen.getByTitle('followers')).toBeVisible();
expect(screen.getByText('3')).toBeVisible();

const replyButton = screen.getByText('View_thread');
expect(replyButton).toBeVisible();
await userEvent.click(replyButton);

expect(navigateSpy).toHaveBeenCalledWith('thread', 'rid', 'thread', 'mid');

const threadCount = screen.getByTitle('Last_message__date__');
expect(threadCount).toHaveTextContent('5 replies');
});
});

describe('ThreadMetricsFollow', () => {
it('should render not followed', async () => {
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={false} />, {
wrapper: mockAppRoot()
.withEndpoint(
'POST',
'/v1/chat.followMessage',
toggleFollowMock(() => undefined),
)
.build(),
legacyRoot: true,
});
const followButton = screen.getByTitle('Not_following');
expect(followButton).toBeVisible();
await userEvent.click(followButton);
});
it('should render followed', async () => {
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={true} />, {
wrapper: mockAppRoot()
.withEndpoint(
'POST',
'/v1/chat.unfollowMessage',
toggleFollowMock(() => undefined),
)
.build(),
legacyRoot: true,
});
const followButton = screen.getByTitle('Following');
expect(followButton).toBeVisible();
await userEvent.click(followButton);
});
it('should render unread badge', () => {
render(<ThreadMetricsFollow unread={true} mention={false} all={false} mid='mid' rid='rid' following={false} />, {
wrapper: mockAppRoot().build(),
legacyRoot: true,
});
const badge = screen.getByTitle('Unread');
expect(badge).toBeVisible();
});
it('should render mention-all badge', () => {
render(<ThreadMetricsFollow unread={true} mention={false} all={true} mid='mid' rid='rid' following={false} />, {
wrapper: mockAppRoot().build(),
legacyRoot: true,
});
const badge = screen.getByTitle('mention-all');
expect(badge).toBeVisible();
});
it('should render Mentions_you badge', () => {
render(<ThreadMetricsFollow unread={true} mention={true} all={false} mid='mid' rid='rid' following={false} />, {
wrapper: mockAppRoot().build(),
legacyRoot: true,
});
const badge = screen.getByTitle('Mentions_you');
expect(badge).toBeVisible();
});
});
describe('ThreadMetricsParticipants', () => {
it('should render 1 avatars', () => {
render(<ThreadMetricsParticipants participants={['user1']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', true)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
expect(screen.getByTitle('follower')).toBeVisible();
const avatars = screen.getAllByRole('figure');
expect(avatars.length).toBe(1);
expect(avatars.pop()).toBeVisible();
});
it('should render 2 avatars', () => {
render(<ThreadMetricsParticipants participants={['user1', 'user2']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', true)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
expect(screen.getByTitle('followers')).toBeVisible();
const avatars = screen.getAllByRole('figure');
expect(avatars.length).toBe(2);
avatars.forEach((avatar) => expect(avatar).toBeVisible());
});
it('should render 2 avatars and "+1" text', () => {
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', true)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
expect(screen.getByTitle('followers')).toBeVisible();
const avatars = screen.getAllByRole('figure');
expect(avatars.length).toBe(2);
avatars.forEach((avatar) => expect(avatar).toBeVisible());
expect(screen.getByText('+1')).toBeVisible();
});
it('should render 2 avatars and "+5" text', () => {
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3', 'user4', 'user5', 'user6', 'user7']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', true)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
expect(screen.getByTitle('followers')).toBeVisible();

const avatars = screen.getAllByRole('figure');
expect(avatars.length).toBe(2);
avatars.forEach((avatar) => expect(avatar).toBeVisible());

expect(screen.getByText('+5')).toBeVisible();
});

it('should render user icon and 1 follower', () => {
render(<ThreadMetricsParticipants participants={['user1']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', false)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
const follower = screen.getByTitle('follower');
expect(follower).toBeVisible();

// eslint-disable-next-line testing-library/no-node-access
expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible();

expect(screen.getByText('1')).toBeVisible();
});

it('should render user icon and 5 followers', () => {
render(<ThreadMetricsParticipants participants={['user1', 'user2', 'user3', 'user4', 'user5']} />, {
wrapper: mockAppRoot()
.withUserPreference('displayAvatars', false)
.withTranslations(...mockedTranslations)
.build(),
legacyRoot: true,
});
const follower = screen.getByTitle('followers');
expect(follower).toBeVisible();

// eslint-disable-next-line testing-library/no-node-access
expect(follower.querySelector('.rcx-icon--name-user')).toBeVisible();

expect(screen.getByText('5')).toBeVisible();
});
});
});
Loading

0 comments on commit 66ecc64

Please sign in to comment.