snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
21
apps/meteor/client/views/hooks/roomActions/useArchiveRoom.ts
Normal file
21
apps/meteor/client/views/hooks/roomActions/useArchiveRoom.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useArchiveRoom = (room: Pick<IRoom, RoomAdminFieldsType>) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const archiveAction = useEndpoint('POST', '/v1/rooms.changeArchivationState');
|
||||
|
||||
const handleArchive = useStableCallback(async () => {
|
||||
try {
|
||||
await archiveAction({ rid: room._id, action: room.archived ? 'unarchive' : 'archive' });
|
||||
dispatchToastMessage({ type: 'success', message: room.archived ? t('Room_has_been_unarchived') : t('Room_has_been_archived') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
});
|
||||
|
||||
return handleArchive;
|
||||
};
|
||||
103
apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx
Normal file
103
apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useToastMessageDispatch, useRouter, usePermission, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTeamInfoQuery } from '../../../hooks/useTeamInfoQuery';
|
||||
import DeleteTeamModal from '../../teams/contextualBar/info/DeleteTeam';
|
||||
|
||||
export const useDeleteRoom = (room: IRoom | Pick<IRoom, RoomAdminFieldsType>, { reload }: { reload?: () => void } = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const roomType = 'prid' in room ? 'discussion' : room.teamId && room.teamMain ? 'team' : 'channel';
|
||||
const isAdminRoute = router.getRouteName() === 'admin-rooms';
|
||||
|
||||
const deleteRoomEndpoint = useEndpoint('POST', '/v1/rooms.delete');
|
||||
const deleteTeamEndpoint = useEndpoint('POST', '/v1/teams.delete');
|
||||
|
||||
const teamId = room.teamId || '';
|
||||
const { data: teamInfo } = useTeamInfoQuery(teamId);
|
||||
|
||||
const hasPermissionToDeleteRoom = usePermission(`delete-${room.t}`, room._id);
|
||||
const hasPermissionToDeleteTeamRoom = usePermission(`delete-team-${room.t === 'c' ? 'channel' : 'group'}`, teamInfo?.roomId);
|
||||
const isTeamRoom = room.teamId;
|
||||
const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDeleteRoom && (!isTeamRoom || hasPermissionToDeleteTeamRoom);
|
||||
|
||||
const deleteRoomMutation = useMutation({
|
||||
mutationFn: deleteRoomEndpoint,
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Deleted_roomType', { roomName: room.name, roomType }) });
|
||||
if (isAdminRoute) {
|
||||
return router.navigate('/admin/rooms');
|
||||
}
|
||||
|
||||
return router.navigate('/home');
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSettled: () => {
|
||||
setModal(null);
|
||||
reload?.();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTeamMutation = useMutation({
|
||||
mutationFn: deleteTeamEndpoint,
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Team_has_been_deleted') });
|
||||
if (isAdminRoute) {
|
||||
return router.navigate('/admin/rooms');
|
||||
}
|
||||
|
||||
return router.navigate('/home');
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSettled: () => {
|
||||
setModal(null);
|
||||
reload?.();
|
||||
},
|
||||
});
|
||||
|
||||
const isDeleting = deleteTeamMutation.isPending || deleteRoomMutation.isPending;
|
||||
|
||||
const handleDelete = useStableCallback(() => {
|
||||
const handleDeleteTeam = async (roomsToRemove: IRoom['_id'][]) => {
|
||||
if (!room.teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteTeamMutation.mutateAsync({ teamId: room.teamId, ...(roomsToRemove.length && { roomsToRemove }) });
|
||||
};
|
||||
|
||||
if (room.teamMain && room.teamId) {
|
||||
return setModal(<DeleteTeamModal onConfirm={handleDeleteTeam} onCancel={(): void => setModal(null)} teamId={room.teamId} />);
|
||||
}
|
||||
|
||||
const handleDeleteRoom = async () => {
|
||||
deleteRoomMutation.mutateAsync({ roomId: room._id });
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal
|
||||
title={t('Delete_roomType', { roomType })}
|
||||
variant='danger'
|
||||
onConfirm={handleDeleteRoom}
|
||||
onCancel={(): void => setModal(null)}
|
||||
confirmText={t('Yes_delete_it')}
|
||||
>
|
||||
{t('Delete_Room_Warning', { roomType })}
|
||||
</GenericModal>,
|
||||
);
|
||||
});
|
||||
|
||||
return { handleDelete, canDeleteRoom, isDeleting };
|
||||
};
|
||||
17
apps/meteor/client/views/hooks/useActiveConnections.ts
Normal file
17
apps/meteor/client/views/hooks/useActiveConnections.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useActiveConnections = (): UseQueryResult<{ max: number; current: number; percentage: number }> => {
|
||||
const getConnections = useEndpoint('GET', '/v1/presence.getConnections');
|
||||
return useQuery({
|
||||
queryKey: ['userConnections'],
|
||||
|
||||
queryFn: async () => {
|
||||
const { current, max } = await getConnections();
|
||||
return { current, max, percentage: Math.min((current / max) * 100, 100) };
|
||||
},
|
||||
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
};
|
||||
13
apps/meteor/client/views/hooks/useMemberExists.ts
Normal file
13
apps/meteor/client/views/hooks/useMemberExists.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
type UseMemberExistsProps = { roomId: string; username: string };
|
||||
|
||||
export const useMemberExists = ({ roomId, username }: UseMemberExistsProps) => {
|
||||
const checkMember = useEndpoint('GET', '/v1/rooms.isMember');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['rooms/isMember', roomId, username],
|
||||
queryFn: () => checkMember({ roomId, username }),
|
||||
});
|
||||
};
|
||||
403
apps/meteor/client/views/hooks/useMemberList.spec.ts
Normal file
403
apps/meteor/client/views/hooks/useMemberList.spec.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { useStream } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import type { RoomMember } from './useMembersList';
|
||||
import { useMembersList } from './useMembersList';
|
||||
|
||||
type MembersPage = {
|
||||
offset: number;
|
||||
count: number;
|
||||
total: number;
|
||||
members: Array<{
|
||||
_id: string;
|
||||
username: string;
|
||||
roles: string[];
|
||||
status?: UserStatus;
|
||||
}>;
|
||||
};
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => {
|
||||
const originalModule = jest.requireActual('@rocket.chat/ui-contexts');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
useStream: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseStream = useStream as jest.MockedFunction<typeof useStream>;
|
||||
const mockDMMembersEndpoint = jest.fn();
|
||||
const mockRoomMembersEndpoint = jest.fn();
|
||||
|
||||
describe('useMembersList', () => {
|
||||
let fakeMembersPage1: MembersPage;
|
||||
let fakeMembersPage2: MembersPage;
|
||||
|
||||
const wrapper = mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withSetting('UI_Use_Real_Name', false)
|
||||
.withEndpoint('GET', '/v1/im.members', (_params) => {
|
||||
mockDMMembersEndpoint();
|
||||
return fakeMembersPage1 as any;
|
||||
})
|
||||
.withEndpoint('GET', '/v1/rooms.membersOrderedByRole', (_params) => {
|
||||
mockRoomMembersEndpoint();
|
||||
return fakeMembersPage1 as any;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
fakeMembersPage1 = {
|
||||
offset: 0,
|
||||
count: 3,
|
||||
total: 5,
|
||||
members: [
|
||||
{ _id: 'user1', username: 'alex', roles: ['owner'], status: UserStatus.ONLINE },
|
||||
{ _id: 'user2', username: 'bob', roles: ['leader'], status: UserStatus.OFFLINE },
|
||||
{ _id: 'user3', username: 'john', roles: ['moderator'], status: UserStatus.OFFLINE },
|
||||
],
|
||||
};
|
||||
|
||||
fakeMembersPage2 = {
|
||||
offset: 3,
|
||||
count: 2,
|
||||
total: 5,
|
||||
members: [
|
||||
{ _id: 'user4', username: 'chris', roles: [], status: UserStatus.ONLINE },
|
||||
{ _id: 'user5', username: 'zoe', roles: [], status: UserStatus.OFFLINE },
|
||||
],
|
||||
};
|
||||
|
||||
mockUseStream.mockReturnValue(() => () => undefined);
|
||||
});
|
||||
|
||||
it('fetches members using the correct endpoint for roomType c', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
await expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(mockDMMembersEndpoint).not.toHaveBeenCalled();
|
||||
expect(mockRoomMembersEndpoint).toHaveBeenCalled();
|
||||
|
||||
expect(result.current.data?.pages[0].members).toHaveLength(fakeMembersPage1.members.length);
|
||||
});
|
||||
|
||||
it('fetches members using the correct endpoint for roomType p', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'p',
|
||||
}),
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(mockRoomMembersEndpoint).toHaveBeenCalled();
|
||||
expect(mockDMMembersEndpoint).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.current.data?.pages[0].members).toHaveLength(fakeMembersPage1.members.length);
|
||||
});
|
||||
|
||||
it('fetches from /v1/im.members if roomType is d', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'directRoomId',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'd',
|
||||
}),
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(mockDMMembersEndpoint).toHaveBeenCalled();
|
||||
expect(mockRoomMembersEndpoint).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.current.data?.pages[0].members).toHaveLength(fakeMembersPage1.members.length);
|
||||
});
|
||||
|
||||
it('applies pagination with fetchNextPage', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{
|
||||
wrapper: wrapper
|
||||
.withEndpoint('GET', '/v1/rooms.membersOrderedByRole', ({ offset }) => {
|
||||
if (offset === 0) {
|
||||
return fakeMembersPage1 as any;
|
||||
}
|
||||
if (offset === 3) {
|
||||
return fakeMembersPage2 as any;
|
||||
}
|
||||
return {
|
||||
members: [],
|
||||
offset,
|
||||
count: 0,
|
||||
total: 5,
|
||||
};
|
||||
})
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.data?.pages[0].members).toHaveLength(3);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
|
||||
expect(result.current.data?.pages[1].members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('subscribes to "roles-change" and unsubscribes on unmount', async () => {
|
||||
const unsubscribeMock = jest.fn();
|
||||
const subscribeMock = jest.fn().mockReturnValue(unsubscribeMock);
|
||||
|
||||
mockUseStream.mockReturnValue(subscribeMock);
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(subscribeMock).toHaveBeenCalledWith('roles-change', expect.any(Function)));
|
||||
|
||||
unmount();
|
||||
expect(unsubscribeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates member roles in the cache on roles-change', async () => {
|
||||
const subscribeMock = jest.fn();
|
||||
let rolesChangeCallback: ((payload: any) => void) | undefined = undefined;
|
||||
|
||||
subscribeMock.mockImplementation((eventName, cb) => {
|
||||
if (eventName === 'roles-change') {
|
||||
rolesChangeCallback = cb;
|
||||
}
|
||||
return () => undefined; // unsubscribe mock
|
||||
});
|
||||
mockUseStream.mockReturnValue(subscribeMock);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 3,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{ wrapper: wrapper.build() },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
let user2 = result.current.data?.pages[0].members.find((m) => m._id === 'user2') as RoomMember;
|
||||
expect(user2?.roles).toEqual(['leader']);
|
||||
|
||||
// Simulate a roles-change event "added" for user2 -> 'owner'
|
||||
await act(async () => {
|
||||
rolesChangeCallback?.({
|
||||
type: 'added',
|
||||
scope: 'room123',
|
||||
u: { _id: 'user2' },
|
||||
_id: 'owner',
|
||||
});
|
||||
});
|
||||
|
||||
user2 = result.current.data?.pages[0].members.find((m) => m._id === 'user2') as RoomMember;
|
||||
|
||||
await waitFor(() => expect(user2?.roles).toContain('owner'));
|
||||
await waitFor(() => expect(user2?.roles).toContain('leader'));
|
||||
});
|
||||
|
||||
it('sorts members list cache by "roles > status > username/name" logic on roles-change', async () => {
|
||||
const customPage = {
|
||||
offset: 0,
|
||||
count: 6,
|
||||
total: 6,
|
||||
members: [
|
||||
{ _id: 'u1', username: 'mark', roles: ['owner'], status: UserStatus.OFFLINE },
|
||||
{ _id: 'u2', username: 'michael', roles: ['leader'], status: UserStatus.OFFLINE },
|
||||
{ _id: 'u3', username: 'karl', roles: ['moderator'], status: UserStatus.ONLINE },
|
||||
{ _id: 'u4', username: 'bob', roles: ['moderator'], status: UserStatus.OFFLINE },
|
||||
{ _id: 'u5', username: 'alex', roles: [], status: UserStatus.OFFLINE },
|
||||
{ _id: 'u6', username: 'john', roles: [], status: UserStatus.ONLINE },
|
||||
],
|
||||
};
|
||||
|
||||
let rolesChangeCallback: ((payload: any) => void) | undefined;
|
||||
mockUseStream.mockReturnValue((eventName, cb) => {
|
||||
if (eventName === 'roles-change') {
|
||||
rolesChangeCallback = cb as any;
|
||||
}
|
||||
return () => undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 5,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{
|
||||
wrapper: wrapper.withEndpoint('GET', '/v1/rooms.membersOrderedByRole', (_params) => customPage as any).build(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
let memberIds = result.current.data?.pages[0].members.map((m) => m._id);
|
||||
expect(memberIds).toEqual(['u1', 'u2', 'u3', 'u4', 'u5', 'u6']);
|
||||
|
||||
// Simulate giving user 'alex/u5' the "moderator" role.
|
||||
// That should push 'alex/u5' to the third of the sorted list -
|
||||
// after mark/u1 and michael/u2 (since owner), after karl/u3 (since online) and before bob/u4 (since sort by username).
|
||||
act(() => {
|
||||
rolesChangeCallback?.({
|
||||
type: 'added',
|
||||
scope: 'room123',
|
||||
u: { _id: 'u5' },
|
||||
_id: 'moderator',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
memberIds = result.current.data?.pages[0].members.map((m) => m._id);
|
||||
expect(memberIds).toEqual(['u1', 'u2', 'u3', 'u5', 'u4', 'u6']);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-sorts members globally across multiple pages on roles-change and verifies exact array match', async () => {
|
||||
const firstPage = {
|
||||
offset: 0,
|
||||
count: 2,
|
||||
total: 4,
|
||||
members: [
|
||||
{ _id: 'userA', username: 'adam', roles: ['owner'], status: UserStatus.OFFLINE },
|
||||
{ _id: 'userB', username: 'bea', roles: ['moderator'], status: UserStatus.OFFLINE },
|
||||
],
|
||||
};
|
||||
|
||||
const secondPage = {
|
||||
offset: 2,
|
||||
count: 2,
|
||||
total: 4,
|
||||
members: [
|
||||
{ _id: 'userC', username: 'charlie', roles: [], status: UserStatus.ONLINE },
|
||||
{ _id: 'userD', username: 'david', roles: [], status: UserStatus.OFFLINE },
|
||||
],
|
||||
};
|
||||
|
||||
let rolesChangeCallback: ((payload: any) => void) | undefined;
|
||||
|
||||
mockUseStream.mockReturnValue((eventName, cb) => {
|
||||
if (eventName === 'roles-change') {
|
||||
rolesChangeCallback = cb as any;
|
||||
}
|
||||
return () => undefined;
|
||||
});
|
||||
|
||||
const testWrapper = wrapper.withEndpoint('GET', '/v1/rooms.membersOrderedByRole', ({ offset }) => {
|
||||
if (offset === 0) {
|
||||
return firstPage as any;
|
||||
}
|
||||
if (offset === 2) {
|
||||
return secondPage as any;
|
||||
}
|
||||
return { offset, count: 2, total: 4, members: [] } as any;
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useMembersList({
|
||||
rid: 'room123',
|
||||
type: 'all',
|
||||
limit: 2,
|
||||
debouncedText: '',
|
||||
roomType: 'c',
|
||||
}),
|
||||
{ wrapper: testWrapper.build() },
|
||||
);
|
||||
|
||||
// Page 1
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(result.current.data?.pages).toHaveLength(1);
|
||||
expect(result.current.data?.pages[0].members.map((m) => m._id)).toStrictEqual(['userA', 'userB']);
|
||||
|
||||
// Page 2
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
|
||||
expect(result.current.data?.pages[1].members.map((m) => m._id)).toStrictEqual(['userC', 'userD']);
|
||||
|
||||
// Give userC the "owner" role
|
||||
await act(async () => {
|
||||
await rolesChangeCallback?.({
|
||||
type: 'added',
|
||||
scope: 'room123',
|
||||
u: { _id: 'userC' },
|
||||
_id: 'owner',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.data?.pages).toHaveLength(2);
|
||||
|
||||
const userC = result.current.data?.pages.flatMap((page) => page.members).find((m) => m._id === 'userC') as RoomMember | null;
|
||||
await waitFor(() => expect(userC?.roles).toContain('owner'));
|
||||
|
||||
// userC now has 'owner' + ONLINE => sits at the top after sorting
|
||||
// then userA (owner), then userB (moderator), then userD (member)
|
||||
expect(result.current.data?.pages[0].members).toHaveLength(2);
|
||||
expect(result.current.data?.pages[1].members).toHaveLength(2);
|
||||
|
||||
await waitFor(() => expect(result.current.data?.pages[0].members.map((m) => m._id)).toStrictEqual(['userC', 'userA']));
|
||||
await waitFor(() => expect(result.current.data?.pages[1].members.map((m) => m._id)).toStrictEqual(['userB', 'userD']));
|
||||
});
|
||||
});
|
||||
159
apps/meteor/client/views/hooks/useMembersList.ts
Normal file
159
apps/meteor/client/views/hooks/useMembersList.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { calculateRoomRolePriorityFromRoles } from '../../../lib/roles/calculateRoomRolePriorityFromRoles';
|
||||
import { roomsQueryKeys } from '../../lib/queryKeys';
|
||||
|
||||
type MembersListOptions = {
|
||||
rid: string;
|
||||
type: 'all' | 'online';
|
||||
limit: number;
|
||||
debouncedText: string;
|
||||
roomType: 'd' | 'p' | 'c';
|
||||
};
|
||||
|
||||
const endpointsByRoomType = {
|
||||
d: '/v1/im.members',
|
||||
p: '/v1/rooms.membersOrderedByRole',
|
||||
c: '/v1/rooms.membersOrderedByRole',
|
||||
} as const;
|
||||
|
||||
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) => ({
|
||||
...member,
|
||||
rolePriority: calculateRoomRolePriorityFromRoles(member.roles ?? []),
|
||||
}));
|
||||
|
||||
return membersWithRolePriority.sort((a, b) => {
|
||||
if (a.rolePriority !== b.rolePriority) {
|
||||
return a.rolePriority - b.rolePriority;
|
||||
}
|
||||
|
||||
if (a.status !== b.status) {
|
||||
if (!a.status || a.status === 'offline') {
|
||||
return 1;
|
||||
}
|
||||
if (!b.status || b.status === 'offline') {
|
||||
return -1;
|
||||
}
|
||||
return a.status.localeCompare(b.status) * -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(
|
||||
roomsQueryKeys.members(options.rid, options.roomType, options.type, options.debouncedText),
|
||||
(oldData: InfiniteData<MembersListPage>) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const allMembers = oldData.pages.flatMap((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 members;
|
||||
});
|
||||
|
||||
const sortedMembers = getSortedMembers(allMembers, useRealName);
|
||||
|
||||
const newPages = oldData.pages.map((page) => {
|
||||
const { count, offset } = page;
|
||||
const newPage = {
|
||||
...page,
|
||||
members: sortedMembers.slice(offset, offset + count),
|
||||
};
|
||||
|
||||
return newPage;
|
||||
});
|
||||
|
||||
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: roomsQueryKeys.members(options.rid, options.roomType, options.type, options.debouncedText),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const start = pageParam ?? 0;
|
||||
|
||||
return getMembers({
|
||||
roomId: options.rid,
|
||||
offset: start,
|
||||
count: options.limit,
|
||||
...(options.debouncedText && { filter: options.debouncedText }),
|
||||
...(options.type !== 'all' && { status: [options.type] }),
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
// if the offset is greater than the total, return undefined to stop the query from trying to fetch another page
|
||||
return offset >= lastPage.total ? undefined : offset;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
});
|
||||
};
|
||||
30
apps/meteor/client/views/hooks/useRequire2faSetup.ts
Normal file
30
apps/meteor/client/views/hooks/useRequire2faSetup.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { Roles } from '../../stores';
|
||||
|
||||
export const useRequire2faSetup = () => {
|
||||
const user = useUser();
|
||||
const tfaEnabled = useSetting('Accounts_TwoFactorAuthentication_Enabled', false);
|
||||
const email2faEnabled = useSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled', false);
|
||||
const totp2faEnabled = useSetting('Accounts_TwoFactorAuthentication_By_TOTP_Enabled', false);
|
||||
const is2FAEnabled = tfaEnabled && (email2faEnabled || totp2faEnabled);
|
||||
|
||||
return Roles.use((state) => {
|
||||
if (!user || !is2FAEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mandatoryRole = state.find((role) => !!role.mandatory2fa && user.roles?.includes(role._id));
|
||||
|
||||
if (mandatoryRole === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasEmail2FA = !!user?.services?.email2fa?.enabled;
|
||||
const hasTotp2FA = !!user?.services?.totp?.enabled;
|
||||
|
||||
const hasAnyEnabled2FA = (email2faEnabled && hasEmail2FA) || (totp2faEnabled && hasTotp2FA);
|
||||
|
||||
return !hasAnyEnabled2FA;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLogout, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import type { MutationOptions } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useResetE2EPasswordMutation = ({ options }: { options?: MutationOptions } = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const logout = useLogout();
|
||||
const resetE2eKey = useEndpoint('POST', '/v1/users.resetE2EKey');
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => resetE2eKey({}),
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('E2EE_password_reset') });
|
||||
logout();
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
41
apps/meteor/client/views/hooks/useRoomBannedUsers.ts
Normal file
41
apps/meteor/client/views/hooks/useRoomBannedUsers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { IUser, RequiredField } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { roomsQueryKeys } from '../../lib/queryKeys';
|
||||
|
||||
export type BannedUser = RequiredField<Pick<IUser, '_id' | 'username' | 'name'>, '_id' | 'username'>;
|
||||
|
||||
type UseRoomBannedUsersProps = {
|
||||
rid: string;
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const useRoomBannedUsers = ({ rid, limit = 50, enabled = true }: UseRoomBannedUsersProps) => {
|
||||
const getBannedUsers = useEndpoint('GET', '/v1/rooms.bannedUsers');
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: roomsQueryKeys.bannedUsers(rid),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const start = pageParam ?? 0;
|
||||
|
||||
return getBannedUsers({
|
||||
roomId: rid,
|
||||
count: limit,
|
||||
offset: start,
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset >= lastPage.total ? undefined : offset;
|
||||
},
|
||||
|
||||
select: ({ pages }) => ({
|
||||
total: pages[pages.length - 1].total,
|
||||
bannedUsers: pages.flatMap((page) => page.bannedUsers) || [],
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
16
apps/meteor/client/views/hooks/useStatistics.ts
Normal file
16
apps/meteor/client/views/hooks/useStatistics.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
type UseStatisticsOptions = {
|
||||
refresh?: 'false' | 'true';
|
||||
};
|
||||
|
||||
export const useStatistics = ({ refresh }: UseStatisticsOptions = { refresh: 'false' }) => {
|
||||
const getStatistics = useEndpoint('GET', '/v1/statistics');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['analytics'],
|
||||
queryFn: () => getStatistics({ refresh }),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user