Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Room roles visibility in members panel #34850

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
6 changes: 6 additions & 0 deletions .changeset/chilled-beers-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Groups members by their roles in the room's member list for improved clarity
103 changes: 99 additions & 4 deletions apps/meteor/client/views/hooks/useMembersList.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { IRole, IUser, AtLeast } from '@rocket.chat/core-typings';
import { useEndpoint, useSetting, useStream } from '@rocket.chat/ui-contexts';
import type { InfiniteData, QueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';

type MembersListOptions = {
rid: string;
Expand All @@ -11,12 +14,104 @@ type MembersListOptions = {

const endpointsByRoomType = {
d: '/v1/im.members',
p: '/v1/groups.members',
c: '/v1/channels.members',
p: '/v1/rooms.membersOrderedByRole',
c: '/v1/rooms.membersOrderedByRole',
} as const;

type RoomMember = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'> & { roles?: IRole['_id'][] };

const getSortedMembers = (members: RoomMember[], useRealName = false) => {
return members.sort((a, b) => {
const aRoles = a.roles ?? [];
const bRoles = b.roles ?? [];
const isOwnerA = aRoles.includes('owner');
const isOwnerB = bRoles.includes('owner');
const isModeratorA = aRoles.includes('moderator');
const isModeratorB = bRoles.includes('moderator');

if (isOwnerA !== isOwnerB) {
return isOwnerA ? -1 : 1;
}

if (isModeratorA !== isModeratorB) {
return isModeratorA ? -1 : 1;
}

if ((a.status === 'online' || b.status === 'online') && a.status !== b.status) {
return a.status === 'online' ? -1 : 1;
}

if (useRealName && a.name && b.name) {
return a.name.localeCompare(b.name);
}

const aUsername = a.username ?? '';
const bUsername = b.username ?? '';
return aUsername.localeCompare(bUsername);
});
};

const updateMemberInCache = (
options: MembersListOptions,
queryClient: QueryClient,
memberId: string,
role: AtLeast<IRole, '_id'>,
type: 'removed' | 'changed' | 'added',
useRealName = false,
) => {
queryClient.setQueryData(
[options.roomType, 'members', options.rid, options.type, options.debouncedText],
(oldData: InfiniteData<{ members: RoomMember[] }>) => {
if (!oldData) {
return oldData;
}

const newPages = oldData.pages.map((page) => {
const members = page.members.map((member) => {
if (member._id === memberId) {
member.roles = member.roles ?? [];
if (type === 'added' && !member.roles.includes(role._id)) {
member.roles.push(role._id);
} else if (type === 'removed') {
member.roles = member.roles.filter((roleId) => roleId !== role._id);
}
}
return member;
});
return {
...page,
members: getSortedMembers(members, useRealName),
};
});

return {
...oldData,
pages: newPages,
};
},
);
};

export const useMembersList = (options: MembersListOptions) => {
const getMembers = useEndpoint('GET', endpointsByRoomType[options.roomType]);
const useRealName = useSetting<boolean>('UI_Use_Real_Name', false);
const queryClient = useQueryClient();

const subscribeToNotifyLoggedIn = useStream('notify-logged');
useEffect(() => {
const unsubscribe = subscribeToNotifyLoggedIn('roles-change', ({ type, ...role }) => {
if (!role.scope) {
return;
}

if (!role.u?._id) {
return;
}

updateMemberInCache(options, queryClient, role.u._id, role as IRole, type, useRealName);
});
return unsubscribe;
}, [options, queryClient, subscribeToNotifyLoggedIn, useRealName]);

return useInfiniteQuery({
queryKey: [options.roomType, 'members', options.rid, options.type, options.debouncedText],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Box } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';

type MembersListDividerProps = {
title: TranslationKey;
count: number;
};

export const MembersListDivider = ({ title, count }: MembersListDividerProps) => {
const { t } = useTranslation();

return (
<Box
key={title}
backgroundColor='room'
height={36}
fontScale='p2m'
color='defaut'
paddingBlock={8}
paddingInline={24}
display='flex'
flexDirection='row'
justifyContent='space-between'
borderBlockEndWidth={1}
borderBlockEndColor='extra-light'
>
<Box>{t(title)}</Box>
<Box>{count}</Box>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
import { useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement, FormEventHandler, ComponentProps, MouseEvent } from 'react';
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { GroupedVirtuoso } from 'react-virtuoso';

import { MembersListDivider } from './MembersListDivider';
import RoomMembersRow from './RoomMembersRow';
import {
ContextualbarHeader,
Expand All @@ -21,7 +22,7 @@ import {
import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars';
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';

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

type RoomMembersProps = {
rid: IRoom['_id'];
Expand Down Expand Up @@ -86,6 +87,32 @@ const RoomMembers = ({

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

const { counts, titles } = useMemo(() => {
const owners = members.filter((member) => member.roles?.includes('owner'));
const moderators = members.filter((member) => !member.roles?.includes('owner') && member.roles?.includes('moderator'));
const normalMembers = members.filter((member) => !member.roles?.includes('owner') && !member.roles?.includes('moderator'));

const counts = [];
const titles = [];

if (owners.length > 0) {
counts.push(owners.length);
titles.push(<MembersListDivider title='Owners' count={owners.length} />);
}

if (moderators.length > 0) {
counts.push(moderators.length);
titles.push(<MembersListDivider title='Moderators' count={moderators.length} />);
}

if (normalMembers.length > 0) {
counts.push(normalMembers.length);
titles.push(<MembersListDivider title='Members' count={normalMembers.length} />);
}

return { counts, titles };
}, [members]);

return (
<>
<ContextualbarHeader data-qa-id='RoomHeader-Members'>
Expand All @@ -105,15 +132,15 @@ const RoomMembers = ({
<Select onChange={(value): void => setType(value as 'online' | 'all')} value={type} options={options} />
</Box>
</ContextualbarSection>
<ContextualbarContent p={12}>
<ContextualbarContent p={0} pb={12}>
{loading && (
<Box pi={24} pb={12}>
<Throbber size='x12' />
</Box>
)}

{error && (
<Box pi={12} pb={12}>
<Box pi={24} pb={12}>
<Callout type='danger'>{error.message}</Callout>
</Box>
)}
Expand All @@ -122,25 +149,25 @@ const RoomMembers = ({

{!loading && members.length > 0 && (
<>
<Box pi={18} pb={12}>
<Box pi={24} pb={12}>
<Box is='span' color='hint' fontScale='p2'>
{t('Showing_current_of_total', { current: members.length, total })}
</Box>
</Box>

<Box w='full' h='full' overflow='hidden' flexShrink={1}>
<Virtuoso
<GroupedVirtuoso
style={{
height: '100%',
width: '100%',
}}
totalCount={total}
overscan={50}
data={members}
groupCounts={counts}
groupContent={(index): ReactElement => titles[index]}
// eslint-disable-next-line react/no-multi-comp
components={{ Scroller: VirtuosoScrollbars, Footer: () => <InfiniteListAnchor loadMore={loadMoreMembers} /> }}
itemContent={(index, data): ReactElement => (
<RowComponent useRealName={useRealName} data={itemData} user={data} index={index} reload={reload} />
itemContent={(index): ReactElement => (
<RowComponent useRealName={useRealName} data={itemData} user={members[index]} index={index} reload={reload} />
)}
/>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const RoomMembersItem = ({
const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName);

return (
<Option data-username={username} data-userid={_id} onClick={onClickView} {...handleMenuEvent}>
<Option data-username={username} data-userid={_id} onClick={onClickView} style={{ paddingInline: 24 }} {...handleMenuEvent}>
<OptionAvatar>
<UserAvatar username={username || ''} size='x28' />
</OptionAvatar>
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -3833,6 +3833,7 @@
"mobile-upload-file_description": "Permission to allow file upload on mobile devices",
"Mobile_Push_Notifications_Default_Alert": "Push Notifications Default Alert",
"Moderation": "Moderation",
"Moderators": "Moderators",
"Moderation_Show_reports": "Show reports",
"Moderation_See_reports": "See reports",
"Moderation_Go_to_message": "Go to message",
Expand Down Expand Up @@ -4269,6 +4270,7 @@
"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",
"Override_Destination_Channel": "Allow to overwrite destination channel in the body parameters",
"Owner": "Owner",
"Owners": "Owners",
"Play": "Play",
"Page_not_exist_or_not_permission": "The page does not exist or you may not have access permission",
"Page_not_found": "Page not found",
Expand Down
Loading