Skip to content

Commit 7ee0f4b

Browse files
feat: Room roles visibility in members panel (#34850)
Signed-off-by: Abhinav Kumar <abhinav@avitechlab.com> Co-authored-by: Abhinav Kumar <abhinav@avitechlab.com>
1 parent e4d6e64 commit 7ee0f4b

File tree

6 files changed

+178
-16
lines changed

6 files changed

+178
-16
lines changed

.changeset/chilled-beers-run.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/i18n': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
Groups members by their roles in the room's member list for improved clarity

apps/meteor/client/views/hooks/useMembersList.ts

+99-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { useEndpoint } from '@rocket.chat/ui-contexts';
2-
import { useInfiniteQuery } from '@tanstack/react-query';
1+
import type { IRole, IUser, AtLeast } from '@rocket.chat/core-typings';
2+
import { useEndpoint, useSetting, useStream } from '@rocket.chat/ui-contexts';
3+
import type { InfiniteData, QueryClient } from '@tanstack/react-query';
4+
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
5+
import { useEffect } from 'react';
36

47
type MembersListOptions = {
58
rid: string;
@@ -11,12 +14,104 @@ type MembersListOptions = {
1114

1215
const endpointsByRoomType = {
1316
d: '/v1/im.members',
14-
p: '/v1/groups.members',
15-
c: '/v1/channels.members',
17+
p: '/v1/rooms.membersOrderedByRole',
18+
c: '/v1/rooms.membersOrderedByRole',
1619
} as const;
1720

21+
type RoomMember = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'> & { roles?: IRole['_id'][] };
22+
23+
const getSortedMembers = (members: RoomMember[], useRealName = false) => {
24+
return members.sort((a, b) => {
25+
const aRoles = a.roles ?? [];
26+
const bRoles = b.roles ?? [];
27+
const isOwnerA = aRoles.includes('owner');
28+
const isOwnerB = bRoles.includes('owner');
29+
const isModeratorA = aRoles.includes('moderator');
30+
const isModeratorB = bRoles.includes('moderator');
31+
32+
if (isOwnerA !== isOwnerB) {
33+
return isOwnerA ? -1 : 1;
34+
}
35+
36+
if (isModeratorA !== isModeratorB) {
37+
return isModeratorA ? -1 : 1;
38+
}
39+
40+
if ((a.status === 'online' || b.status === 'online') && a.status !== b.status) {
41+
return a.status === 'online' ? -1 : 1;
42+
}
43+
44+
if (useRealName && a.name && b.name) {
45+
return a.name.localeCompare(b.name);
46+
}
47+
48+
const aUsername = a.username ?? '';
49+
const bUsername = b.username ?? '';
50+
return aUsername.localeCompare(bUsername);
51+
});
52+
};
53+
54+
const updateMemberInCache = (
55+
options: MembersListOptions,
56+
queryClient: QueryClient,
57+
memberId: string,
58+
role: AtLeast<IRole, '_id'>,
59+
type: 'removed' | 'changed' | 'added',
60+
useRealName = false,
61+
) => {
62+
queryClient.setQueryData(
63+
[options.roomType, 'members', options.rid, options.type, options.debouncedText],
64+
(oldData: InfiniteData<{ members: RoomMember[] }>) => {
65+
if (!oldData) {
66+
return oldData;
67+
}
68+
69+
const newPages = oldData.pages.map((page) => {
70+
const members = page.members.map((member) => {
71+
if (member._id === memberId) {
72+
member.roles = member.roles ?? [];
73+
if (type === 'added' && !member.roles.includes(role._id)) {
74+
member.roles.push(role._id);
75+
} else if (type === 'removed') {
76+
member.roles = member.roles.filter((roleId) => roleId !== role._id);
77+
}
78+
}
79+
return member;
80+
});
81+
return {
82+
...page,
83+
members: getSortedMembers(members, useRealName),
84+
};
85+
});
86+
87+
return {
88+
...oldData,
89+
pages: newPages,
90+
};
91+
},
92+
);
93+
};
94+
1895
export const useMembersList = (options: MembersListOptions) => {
1996
const getMembers = useEndpoint('GET', endpointsByRoomType[options.roomType]);
97+
const useRealName = useSetting<boolean>('UI_Use_Real_Name', false);
98+
const queryClient = useQueryClient();
99+
100+
const subscribeToNotifyLoggedIn = useStream('notify-logged');
101+
useEffect(() => {
102+
const unsubscribe = subscribeToNotifyLoggedIn('roles-change', ({ type, ...role }) => {
103+
if (!role.scope) {
104+
return;
105+
}
106+
107+
if (!role.u?._id) {
108+
return;
109+
}
110+
111+
updateMemberInCache(options, queryClient, role.u._id, role as IRole, type, useRealName);
112+
});
113+
return unsubscribe;
114+
}, [options, queryClient, subscribeToNotifyLoggedIn, useRealName]);
20115

21116
return useInfiniteQuery({
22117
queryKey: [options.roomType, 'members', options.rid, options.type, options.debouncedText],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Box } from '@rocket.chat/fuselage';
2+
import type { TranslationKey } from '@rocket.chat/ui-contexts';
3+
import { useTranslation } from 'react-i18next';
4+
5+
type MembersListDividerProps = {
6+
title: TranslationKey;
7+
count: number;
8+
};
9+
10+
export const MembersListDivider = ({ title, count }: MembersListDividerProps) => {
11+
const { t } = useTranslation();
12+
13+
return (
14+
<Box
15+
key={title}
16+
backgroundColor='room'
17+
height={36}
18+
fontScale='p2m'
19+
color='defaut'
20+
paddingBlock={8}
21+
paddingInline={24}
22+
display='flex'
23+
flexDirection='row'
24+
justifyContent='space-between'
25+
borderBlockEndWidth={1}
26+
borderBlockEndColor='extra-light'
27+
>
28+
<Box>{t(title)}</Box>
29+
<Box>{count}</Box>
30+
</Box>
31+
);
32+
};

apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx

+38-11
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import type { IRoom, IUser } from '@rocket.chat/core-typings';
1+
import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings';
22
import type { SelectOption } from '@rocket.chat/fuselage';
33
import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
44
import { useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
55
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
66
import type { ReactElement, FormEventHandler, ComponentProps, MouseEvent } from 'react';
77
import { useMemo } from 'react';
8-
import { Virtuoso } from 'react-virtuoso';
8+
import { GroupedVirtuoso } from 'react-virtuoso';
99

10+
import { MembersListDivider } from './MembersListDivider';
1011
import RoomMembersRow from './RoomMembersRow';
1112
import {
1213
ContextualbarHeader,
@@ -21,7 +22,7 @@ import {
2122
import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars';
2223
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';
2324

24-
type RoomMemberUser = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'>;
25+
export type RoomMemberUser = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'> & { roles?: IRole['_id'][] };
2526

2627
type RoomMembersProps = {
2728
rid: IRoom['_id'];
@@ -86,6 +87,32 @@ const RoomMembers = ({
8687

8788
const useRealName = useSetting('UI_Use_Real_Name', false);
8889

90+
const { counts, titles } = useMemo(() => {
91+
const owners = members.filter((member) => member.roles?.includes('owner'));
92+
const moderators = members.filter((member) => !member.roles?.includes('owner') && member.roles?.includes('moderator'));
93+
const normalMembers = members.filter((member) => !member.roles?.includes('owner') && !member.roles?.includes('moderator'));
94+
95+
const counts = [];
96+
const titles = [];
97+
98+
if (owners.length > 0) {
99+
counts.push(owners.length);
100+
titles.push(<MembersListDivider title='Owners' count={owners.length} />);
101+
}
102+
103+
if (moderators.length > 0) {
104+
counts.push(moderators.length);
105+
titles.push(<MembersListDivider title='Moderators' count={moderators.length} />);
106+
}
107+
108+
if (normalMembers.length > 0) {
109+
counts.push(normalMembers.length);
110+
titles.push(<MembersListDivider title='Members' count={normalMembers.length} />);
111+
}
112+
113+
return { counts, titles };
114+
}, [members]);
115+
89116
return (
90117
<>
91118
<ContextualbarHeader data-qa-id='RoomHeader-Members'>
@@ -105,15 +132,15 @@ const RoomMembers = ({
105132
<Select onChange={(value): void => setType(value as 'online' | 'all')} value={type} options={options} />
106133
</Box>
107134
</ContextualbarSection>
108-
<ContextualbarContent p={12}>
135+
<ContextualbarContent p={0} pb={12}>
109136
{loading && (
110137
<Box pi={24} pb={12}>
111138
<Throbber size='x12' />
112139
</Box>
113140
)}
114141

115142
{error && (
116-
<Box pi={12} pb={12}>
143+
<Box pi={24} pb={12}>
117144
<Callout type='danger'>{error.message}</Callout>
118145
</Box>
119146
)}
@@ -122,25 +149,25 @@ const RoomMembers = ({
122149

123150
{!loading && members.length > 0 && (
124151
<>
125-
<Box pi={18} pb={12}>
152+
<Box pi={24} pb={12}>
126153
<Box is='span' color='hint' fontScale='p2'>
127154
{t('Showing_current_of_total', { current: members.length, total })}
128155
</Box>
129156
</Box>
130157

131158
<Box w='full' h='full' overflow='hidden' flexShrink={1}>
132-
<Virtuoso
159+
<GroupedVirtuoso
133160
style={{
134161
height: '100%',
135162
width: '100%',
136163
}}
137-
totalCount={total}
138164
overscan={50}
139-
data={members}
165+
groupCounts={counts}
166+
groupContent={(index): ReactElement => titles[index]}
140167
// eslint-disable-next-line react/no-multi-comp
141168
components={{ Scroller: VirtuosoScrollbars, Footer: () => <InfiniteListAnchor loadMore={loadMoreMembers} /> }}
142-
itemContent={(index, data): ReactElement => (
143-
<RowComponent useRealName={useRealName} data={itemData} user={data} index={index} reload={reload} />
169+
itemContent={(index): ReactElement => (
170+
<RowComponent useRealName={useRealName} data={itemData} user={members[index]} index={index} reload={reload} />
144171
)}
145172
/>
146173
</Box>

apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersItem.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const RoomMembersItem = ({
5050
const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName);
5151

5252
return (
53-
<Option data-username={username} data-userid={_id} onClick={onClickView} {...handleMenuEvent}>
53+
<Option data-username={username} data-userid={_id} onClick={onClickView} style={{ paddingInline: 24 }} {...handleMenuEvent}>
5454
<OptionAvatar>
5555
<UserAvatar username={username || ''} size='x28' />
5656
</OptionAvatar>

packages/i18n/src/locales/en.i18n.json

+2
Original file line numberDiff line numberDiff line change
@@ -3833,6 +3833,7 @@
38333833
"mobile-upload-file_description": "Permission to allow file upload on mobile devices",
38343834
"Mobile_Push_Notifications_Default_Alert": "Push Notifications Default Alert",
38353835
"Moderation": "Moderation",
3836+
"Moderators": "Moderators",
38363837
"Moderation_Show_reports": "Show reports",
38373838
"Moderation_See_reports": "See reports",
38383839
"Moderation_Go_to_message": "Go to message",
@@ -4269,6 +4270,7 @@
42694270
"Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Override URL to which files are uploaded. This url also used for downloads unless a CDN is given",
42704271
"Override_Destination_Channel": "Allow to overwrite destination channel in the body parameters",
42714272
"Owner": "Owner",
4273+
"Owners": "Owners",
42724274
"Play": "Play",
42734275
"Page_not_exist_or_not_permission": "The page does not exist or you may not have access permission",
42744276
"Page_not_found": "Page not found",

0 commit comments

Comments
 (0)