Skip to content

Commit

Permalink
feat: add endpoint rooms.membersOrderedByRole (#34153)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
  • Loading branch information
abhinavkrin and MartinSchoeler authored Jan 17, 2025
1 parent 3c237b2 commit c8e8518
Show file tree
Hide file tree
Showing 30 changed files with 1,233 additions and 19 deletions.
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

0 comments on commit c8e8518

Please sign in to comment.