diff --git a/.changeset/chilled-beers-run.md b/.changeset/chilled-beers-run.md new file mode 100644 index 000000000000..efc57189b5ea --- /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/.changeset/silly-kings-approve.md b/.changeset/silly-kings-approve.md new file mode 100644 index 000000000000..abe68fb8d92a --- /dev/null +++ b/.changeset/silly-kings-approve.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Adds `rooms.membersOrderedByRole` endpoint to retrieve members of groups and channels sorted according to their respective role in the room. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 8b545b0b8e2a..589b7af3d883 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,5 +1,6 @@ import { Media, Team } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; +import { isPrivateRoom, isPublicRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; import { @@ -10,6 +11,7 @@ import { isRoomsIsMemberProps, isRoomsCleanHistoryProps, isRoomsOpenProps, + isRoomsMembersOrderedByRoleProps, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -17,6 +19,7 @@ import { isTruthy } from '../../../../lib/isTruthy'; import { omit } from '../../../../lib/utils/omit'; import * as dataExport from '../../../../server/lib/dataExport'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; +import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole'; import { openRoom } from '../../../../server/lib/openRoom'; import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom'; import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom'; @@ -26,6 +29,7 @@ import { saveRoomSettings } from '../../../channel-settings/server/methods/saveR import { createDiscussion } from '../../../discussion/server/methods/createDiscussion'; import { FileUpload } from '../../../file-upload/server'; import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage'; +import { syncRolePrioritiesForRoomIfRequired } from '../../../lib/server/functions/syncRolePrioritiesForRoomIfRequired'; import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom'; import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper'; import { settings } from '../../../settings/server'; @@ -857,6 +861,58 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'rooms.membersOrderedByRole', + { authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps }, + { + async get() { + const findResult = await findRoomByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (!(await canAccessRoomAsync(findResult, this.user))) { + return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); + } + + if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { + return API.v1.failure('error-room-type-not-supported'); + } + + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } + + // Ensures that role priorities for the specified room are synchronized correctly. + // This function acts as a soft migration. If the `roomRolePriorities` field + // for the room has already been created and is up-to-date, no updates will be performed. + // If not, it will synchronize the role priorities of the users of the room. + await syncRolePrioritiesForRoomIfRequired(findResult._id); + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult._id, + ...(status && { status: { $in: status } }), + skip, + limit, + filter, + sort, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, + }, +); + API.v1.addRoute( 'rooms.muteUser', { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index c9d5b0e31075..c9b5354623b7 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -11,6 +11,7 @@ import { createDirectRoom } from './createDirectRoom'; import { callbacks } from '../../../../lib/callbacks'; import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; +import { calculateRoomRolePriorityFromRoles, syncRoomRolePriorityForUserAndRoom } from '../../../../server/lib/roles/syncRoomRolePriority'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; import { notifyOnRoomChanged, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; @@ -48,6 +49,7 @@ async function createUsersSubscriptions({ }; const { insertedId } = await Subscriptions.createWithRoomAndUser(room, owner, extra); + await syncRoomRolePriorityForUserAndRoom(owner._id, room._id, ['owner']); if (insertedId) { await notifyOnRoomChanged(room, 'inserted'); @@ -60,6 +62,8 @@ async function createUsersSubscriptions({ const memberIds = []; + const memberIdAndRolePriorityMap: Record = {}; + const membersCursor = Users.findUsersByUsernames>(members, { projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, }); @@ -95,6 +99,10 @@ async function createUsersSubscriptions({ ...getDefaultSubscriptionPref(member), }, }); + + if (extra.roles) { + memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles); + } } if (!['d', 'l'].includes(room.t)) { @@ -102,6 +110,7 @@ async function createUsersSubscriptions({ } const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs); + await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id); Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted')); diff --git a/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts b/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts new file mode 100644 index 000000000000..e7445d9fac97 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/syncRolePrioritiesForRoomIfRequired.ts @@ -0,0 +1,68 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; + +import { calculateRoomRolePriorityFromRoles } from '../../../../server/lib/roles/syncRoomRolePriority'; + +const READ_BATCH_SIZE = 1000; + +async function assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap: Map) { + const bulk = Users.col.initializeUnorderedBulkOp(); + + userIdAndRoomRolePrioritiesMap.forEach((roomRolePriorities, userId) => { + userIdAndRoomRolePrioritiesMap.delete(userId); + + if (roomRolePriorities) { + const updateFields = Object.entries(roomRolePriorities).reduce( + (operations, rolePriorityData) => { + const [rid, rolePriority] = rolePriorityData; + operations[`roomRolePriorities.${rid}`] = rolePriority; + return operations; + }, + {} as Record, + ); + + bulk.find({ _id: userId }).updateOne({ + $set: updateFields, + }); + } + }); + + if (bulk.length > 0) { + await bulk.execute(); + } +} + +export const syncRolePrioritiesForRoomIfRequired = async (rid: IRoom['_id']) => { + const userIdAndRoomRolePrioritiesMap = new Map(); + + if (await Rooms.hasCreatedRolePrioritiesForRoom(rid)) { + return; + } + + const cursor = Subscriptions.find( + { rid, roles: { $exists: true } }, + { + projection: { 'rid': 1, 'roles': 1, 'u._id': 1 }, + sort: { _id: 1 }, + }, + ).batchSize(READ_BATCH_SIZE); + + for await (const sub of cursor) { + if (!sub.roles?.length) { + continue; + } + + const userId = sub.u._id; + const roomId = sub.rid; + const priority = calculateRoomRolePriorityFromRoles(sub.roles); + + const existingPriorities = userIdAndRoomRolePrioritiesMap.get(userId) || {}; + existingPriorities[roomId] = priority; + userIdAndRoomRolePrioritiesMap.set(userId, existingPriorities); + } + + // Flush any remaining priorities in the map + await assignRoomRolePrioritiesFromMap(userIdAndRoomRolePrioritiesMap); + + await Rooms.markRolePrioritesCreatedForRoom(rid); +}; diff --git a/apps/meteor/client/views/hooks/useMembersList.ts b/apps/meteor/client/views/hooks/useMembersList.ts index 84c95c2d373b..1d9bf534eea3 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 000000000000..8e9abf5c567d --- /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 9ace69a77b02..5f156a4f1d4e 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 = ({