diff --git a/.changeset/chilled-beers-run.md b/.changeset/chilled-beers-run.md new file mode 100644 index 0000000000000..efc57189b5ea6 --- /dev/null +++ b/.changeset/chilled-beers-run.md @@ -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 diff --git a/apps/meteor/client/views/hooks/useMembersList.ts b/apps/meteor/client/views/hooks/useMembersList.ts index 84c95c2d373b0..1d9bf534eea35 100644 --- a/apps/meteor/client/views/hooks/useMembersList.ts +++ b/apps/meteor/client/views/hooks/useMembersList.ts @@ -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; @@ -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 & { 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, + 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('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], diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/MembersListDivider.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/MembersListDivider.tsx new file mode 100644 index 0000000000000..8e9abf5c567d8 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/MembersListDivider.tsx @@ -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 ( + + {t(title)} + {count} + + ); +}; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx index 9ace69a77b028..5f156a4f1d4e9 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx @@ -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, @@ -21,7 +22,7 @@ import { import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import InfiniteListAnchor from '../../../../components/InfiniteListAnchor'; -type RoomMemberUser = Pick; +export type RoomMemberUser = Pick & { roles?: IRole['_id'][] }; type RoomMembersProps = { rid: IRoom['_id']; @@ -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(); + } + + if (moderators.length > 0) { + counts.push(moderators.length); + titles.push(); + } + + if (normalMembers.length > 0) { + counts.push(normalMembers.length); + titles.push(); + } + + return { counts, titles }; + }, [members]); + return ( <> @@ -105,7 +132,7 @@ const RoomMembers = ({