Skip to content
5 changes: 5 additions & 0 deletions .changeset/nasty-moons-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Adds invitation badge to room members list
20 changes: 19 additions & 1 deletion apps/meteor/app/api/server/v1/im.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,26 @@ API.v1.addRoute(

const [members, total] = await Promise.all([cursor.toArray(), totalCount]);

// find subscriptions of those users
const subs = await Subscriptions.findByRoomIdAndUserIds(
room._id,
members.map((member) => member._id),
{ projection: { u: 1, status: 1, ts: 1, roles: 1 } },
).toArray();

const membersWithSubscriptionInfo = members.map((member) => {
const sub = subs.find((sub) => sub.u._id === member._id);

const { u: _u, ...subscription } = sub || {};

return {
...member,
subscription,
};
});
Comment on lines +426 to +435
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix type safety issue with subscription fallback.

When a subscription is not found, the current code creates an empty object {} instead of undefined. This results in a subscription field that doesn't match the expected type Pick<ISubscription, '_id' | 'status' | 'ts' | 'roles'>, potentially causing runtime errors when components try to access properties like subscription._id or subscription.status.

🔎 Proposed fix
 		const membersWithSubscriptionInfo = members.map((member) => {
 			const sub = subs.find((sub) => sub.u._id === member._id);
 
-			const { u: _u, ...subscription } = sub || {};
-
-			return {
-				...member,
-				subscription,
-			};
+			if (!sub) {
+				return member;
+			}
+
+			const { u: _u, ...subscription } = sub;
+
+			return {
+				...member,
+				subscription,
+			};
 		});
🤖 Prompt for AI Agents
In apps/meteor/app/api/server/v1/im.ts around lines 426 to 435, the mapping
creates an empty object when no subscription is found which breaks the expected
Pick<ISubscription, '_id' | 'status' | 'ts' | 'roles'> type; update the map so
that when subs.find(...) returns undefined you set subscription to undefined
(not {}), and when a sub exists explicitly pick only the allowed fields (or
construct the Pick) before returning — i.e. guard the destructure against
undefined and assign subscription = undefined when absent, or subscription = {
_id: sub._id, status: sub.status, ts: sub.ts, roles: sub.roles } when present.


return API.v1.success({
members,
members: membersWithSubscriptionInfo,
count: members.length,
offset,
total,
Expand Down
17 changes: 13 additions & 4 deletions apps/meteor/client/views/hooks/useMembersList.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IRole, IUser, AtLeast } from '@rocket.chat/core-typings';
import type { IRole, IUser, AtLeast, ISubscription, Serialized } 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';
Expand All @@ -20,9 +20,18 @@ const endpointsByRoomType = {
c: '/v1/rooms.membersOrderedByRole',
} as const;

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

type MembersListPage = { members: RoomMember[]; count: number; total: number; offset: number };
export type RoomMember = Serialized<
Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'federated' | 'freeSwitchExtension'> & { roles?: IRole['_id'][] } & {
subscription: Pick<ISubscription, '_id' | 'status' | 'ts' | 'roles'>;
}
>;

type MembersListPage = {
members: RoomMember[];
count: number;
total: number;
offset: number;
};

const getSortedMembers = (members: RoomMember[], useRealName = false) => {
const membersWithRolePriority: (RoomMember & { rolePriority: number })[] = members.map((member) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ Default.args = {
username: 'rocket.cat',
status: UserStatus.ONLINE,
name: 'Rocket.Cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
ts: '2025-01-01T00:00:00Z',
},
},
],
text: 'filter',
Expand Down Expand Up @@ -57,6 +62,40 @@ WithABACRoom.args = {
username: 'rocket.cat',
status: UserStatus.ONLINE,
name: 'Rocket.Cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
ts: '2025-01-01T00:00:00Z',
},
},
],
text: 'filter',
type: 'online',
setText: action('Lorem Ipsum'),
setType: action('online'),
total: 123,
loadMoreItems: action('loadMoreItems'),
rid: '!roomId',
isTeam: false,
isDirect: false,
reload: action('reload'),
isABACRoom: true,
};

export const WithInvitedMember = Template.bind({});
WithInvitedMember.args = {
loading: false,
members: [
{
_id: 'rocket.cat',
username: 'rocket.cat',
roles: ['user'],
subscription: {
_id: 'sub-rocket.cat',
status: 'INVITED',
ts: '2025-01-01T00:00:00Z',
},
name: 'Rocket.Cat',
},
],
text: 'filter',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings';
import type { IRoom } 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';
Expand All @@ -22,8 +22,7 @@ import { GroupedVirtuoso } from 'react-virtuoso';
import { MembersListDivider } from './MembersListDivider';
import RoomMembersRow from './RoomMembersRow';
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';

export type RoomMemberUser = Pick<IUser, 'username' | '_id' | 'name' | 'status' | 'freeSwitchExtension'> & { roles?: IRole['_id'][] };
import type { RoomMember } from '../../../hooks/useMembersList';

type RoomMembersProps = {
rid: IRoom['_id'];
Expand All @@ -34,7 +33,7 @@ type RoomMembersProps = {
type: string;
setText: FormEventHandler<HTMLInputElement>;
setType: (type: 'online' | 'all') => void;
members: RoomMemberUser[];
members: RoomMember[];
total: number;
error?: Error;
onClickClose: () => void;
Expand Down Expand Up @@ -91,10 +90,10 @@ const RoomMembers = ({
const useRealName = useSetting('UI_Use_Real_Name', false);

const { counts, titles } = useMemo(() => {
const owners: RoomMemberUser[] = [];
const leaders: RoomMemberUser[] = [];
const moderators: RoomMemberUser[] = [];
const normalMembers: RoomMemberUser[] = [];
const owners: RoomMember[] = [];
const leaders: RoomMember[] = [];
const moderators: RoomMember[] = [];
const normalMembers: RoomMember[] = [];

members.forEach((member) => {
if (member.roles?.includes('owner')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { IRoom } from '@rocket.chat/core-typings';
import {
Option,
OptionAvatar,
Expand All @@ -17,15 +17,17 @@ import { useState } from 'react';

import UserActions from './RoomMembersActions';
import { getUserDisplayNames } from '../../../../../lib/getUserDisplayNames';
import InvitationBadge from '../../../../components/InvitationBadge';
import { ReactiveUserStatus } from '../../../../components/UserStatus';
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
import type { RoomMember } from '../../../hooks/useMembersList';

type RoomMembersItemProps = {
onClickView: (e: MouseEvent<HTMLElement>) => void;
type RoomMembersItemProps = Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'> & {
rid: IRoom['_id'];
reload: () => void;
useRealName: boolean;
} & Pick<IUser, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension'>;
reload: () => void;
onClickView: (e: MouseEvent<HTMLElement>) => void;
};

const RoomMembersItem = ({
_id,
Expand All @@ -37,6 +39,7 @@ const RoomMembersItem = ({
rid,
reload,
useRealName,
subscription,
}: RoomMembersItemProps): ReactElement => {
const [showButton, setShowButton] = useState();
const isReduceMotionEnabled = usePrefersReducedMotion();
Expand All @@ -57,6 +60,11 @@ const RoomMembersItem = ({
<OptionContent data-qa={`MemberItem-${username}`}>
{nameOrUsername} {displayUsername && <OptionDescription>({displayUsername})</OptionDescription>}
</OptionContent>
{subscription?.status === 'INVITED' && (
<OptionColumn>
<InvitationBadge mbs={2} size='x20' invitationDate={subscription.ts} />
</OptionColumn>
)}
<OptionMenu onClick={preventPropagation}>
{showButton ? (
<UserActions username={username} name={name} rid={rid} _id={_id} freeSwitchExtension={freeSwitchExtension} reload={reload} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import type { IRoom } from '@rocket.chat/core-typings';
import type { MouseEvent, ReactElement } from 'react';
import { memo } from 'react';

import RoomMembersItem from './RoomMembersItem';
import type { RoomMember } from '../../../hooks/useMembersList';

type RoomMembersRowProps = {
user: Pick<IUser, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension'>;
user: Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'>;
data: {
onClickView: (e: MouseEvent<HTMLElement>) => void;
rid: IRoom['_id'];
Expand All @@ -30,6 +31,7 @@ const RoomMembersRow = ({ user, data: { onClickView, rid }, index, reload, useRe
name={user.name}
federated={user.federated}
freeSwitchExtension={user.freeSwitchExtension}
subscription={user.subscription}
onClickView={onClickView}
reload={reload}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,172 @@ exports[`renders WithABACRoom without crashing 1`] = `
</div>
</body>
`;

exports[`renders WithInvitedMember without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-yefw2m"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-20qf5w"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-zsa0ng"
>
<div
class="rcx-box rcx-box--full rcx-css-1sl6k6j"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-members rcx-icon rcx-css-x7bl3q rcx-css-g86psg"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-x7bl3q rcx-css-1to6ka7"
id="contextualbarTitle"
>
Members
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__section rcx-css-137qgp8"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
placeholder="Search_by_username"
size="1"
type="text"
value="filter"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<div
class="rcx-box rcx-box--full rcx-css-l1qvi5"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-:r13:"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
type="button"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="online"
>
online
</option>
<option
value="all"
>
all
</option>
</select>
</label>
</div>
<span
class="rcx-box rcx-box--full rcx-css-e3nij6"
id="react-aria-:r13:"
>
Online
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-1wz6xj9"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-1w66n2o"
>
<div
class="rcx-box rcx-box--full rcx-css-nyqak3"
>
<span
class="rcx-box rcx-box--full rcx-css-1848tdm"
>
Showing_current_of_total
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-4k230l"
>
<div
class="rcx-box rcx-box--full rcx-css-vlo1oi rcx-css-1cb6i7s"
>
<div
data-testid="virtuoso-scroller"
data-virtuoso-scroller="true"
style="height: 100%; outline: none; overflow-y: auto; position: relative; width: 100%;"
tabindex="-1"
>
<div
style="width: 100%; position: -webkit-sticky; top: 0px; z-index: 1; margin-top: 0px;"
>
<div
data-testid="virtuoso-top-item-list"
/>
</div>
<div
data-viewport-type="element"
style="width: 100%; height: 100%; position: absolute; top: 0px;"
>
<div
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-top: 0px; padding-bottom: 0px;"
/>
<div>
<div
class="rcx-box rcx-box--full rcx-css-jsq7k5"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;
Loading
Loading