diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 413cab7f6ea0c..9b1bf73c6f9a5 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -15,6 +15,7 @@ type UserAutoCompleteMultipleProps = { placeholder?: string; federated?: boolean; error?: string; + exceptions?: string[]; } & Omit, 'is' | 'onChange' | 'value'>; type UserAutoCompleteOptionType = { @@ -29,7 +30,7 @@ type UserAutoCompleteOptions = { const matrixRegex = new RegExp('@(.*:.*)'); -const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, ...props }: UserAutoCompleteMultipleProps): ReactElement => { +const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, exceptions = [], ...props }: UserAutoCompleteMultipleProps): ReactElement => { const [filter, setFilter] = useState(''); const [selectedCache, setSelectedCache] = useState({}); @@ -37,10 +38,10 @@ const UserAutoCompleteMultiple = ({ onChange, value, placeholder, federated, ... const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); const { data } = useQuery({ - queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false), + queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false, exceptions), queryFn: async () => { - const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter, exceptions }) }); const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); // Add extra option if filter text matches `username:server` diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 44529ad6d2543..37f5c07bb1a64 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -119,7 +119,9 @@ export const usersQueryKeys = { all: ['users'] as const, userInfo: ({ uid, username }: { uid?: IUser['_id']; username?: IUser['username'] }) => [...usersQueryKeys.all, 'info', { uid, username }] as const, - userAutoComplete: (filter: string, federated: boolean) => [...usersQueryKeys.all, 'autocomplete', filter, federated] as const, + userAutoComplete: (filter: string, federated: boolean, exceptions: string[] = []) => + // Expects exceptions to be pre-sorted in the calling component for cache efficiency + [...usersQueryKeys.all, 'autocomplete', filter, federated, exceptions] as const, }; export const teamsQueryKeys = { diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx index 7c7151d056c5f..b57e66da9520a 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx @@ -11,8 +11,9 @@ import { ContextualbarFooter, ContextualbarDialog, } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch, useMethod, useRoomToolbox } from '@rocket.chat/ui-contexts'; -import { useId } from 'react'; +import { useToastMessageDispatch, useMethod, useRoomToolbox, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useId, useMemo } from 'react'; import type { ReactElement } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -42,6 +43,34 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => const { closeTab } = useRoomToolbox(); const saveAction = useMethod('addUsersToRoom'); + // Fetch existing room members to exclude them from autocomplete + // Note: Limited to 100 members due to API_Upper_Count_Limit setting + // In very large channels (>100 members), some existing members might still appear in autocomplete + const getRoomMembers = useEndpoint('GET', '/v1/rooms.membersOrderedByRole'); + const { data: membersData, isError: isMembersQueryError } = useQuery({ + queryKey: ['room-members', rid], + queryFn: () => getRoomMembers({ roomId: rid, offset: 0, count: 100 }), + // Disable query if room type is not supported + enabled: room.t === 'c' || room.t === 'p', + }); + + const existingMemberUsernames = useMemo(() => { + // If the query is disabled or failed, return empty array (no exceptions) + // This means existing members might appear in autocomplete, but backend will handle duplicates + if (isMembersQueryError || !membersData?.members) { + return []; + } + // Use single iteration with reduce for better performance + return membersData.members + .reduce((acc, member) => { + if (member.username) { + acc.push(member.username); + } + return acc; + }, []) + .sort(); // Sort here for consistent query cache keys + }, [membersData, isMembersQueryError]); + const { handleSubmit, control, @@ -84,6 +113,7 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => federated={isFederated} placeholder={t('Choose_users')} aria-describedby={`${usersFieldId}-error`} + exceptions={existingMemberUsernames} {...field} /> )} diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts index f2d348240f3cf..c0feecae17016 100644 --- a/apps/meteor/tests/end-to-end/api/channels.ts +++ b/apps/meteor/tests/end-to-end/api/channels.ts @@ -250,6 +250,28 @@ describe('[Channels]', () => { }); }); + it('/channels.invite should still succeed when inviting a user already in the channel', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + // When adding an already-existing user, no new system message is created + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); + }); + }); + it('/channels.addOwner', (done) => { void request .post(api('channels.addOwner'))