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: add endpoint rooms.membersOrderedByRole #34153

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
6 changes: 6 additions & 0 deletions .changeset/silly-kings-approve.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,13 +11,15 @@ import {
isRoomsIsMemberProps,
isRoomsCleanHistoryProps,
isRoomsOpenProps,
isRoomsMembersOrderedByRoleProps,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';

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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions apps/meteor/app/lib/server/functions/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -60,6 +62,8 @@ async function createUsersSubscriptions({

const memberIds = [];

const memberIdAndRolePriorityMap: Record<IUser['_id'], number> = {};

const membersCursor = Users.findUsersByUsernames<Pick<IUser, '_id' | 'username' | 'settings' | 'federated' | 'roles'>>(members, {
projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 },
});
Expand Down Expand Up @@ -95,13 +99,18 @@ async function createUsersSubscriptions({
...getDefaultSubscriptionPref(member),
},
});

if (extra.roles) {
memberIdAndRolePriorityMap[member._id] = calculateRoomRolePriorityFromRoles(extra.roles);
}
}

if (!['d', 'l'].includes(room.t)) {
await Users.addRoomByUserIds(memberIds, room._id);
}

const { insertedIds } = await Subscriptions.createWithRoomAndManyUsers(room, subs);
await Users.assignRoomRolePrioritiesByUserIdPriorityMap(memberIdAndRolePriorityMap, room._id);

Object.values(insertedIds).forEach((subId) => notifyOnSubscriptionChangedById(subId, 'inserted'));

Expand Down
Original file line number Diff line number Diff line change
@@ -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<IUser['_id'], IUser['roomRolePriorities']>) {
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<string, number>,
);

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<IUser['_id'], IUser['roomRolePriorities']>();

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);
};
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>
);
};
Loading
Loading