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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
import { useAppsContextualBar } from './useAppsContextualBar';
import { useUiKitActionManager } from '../../../uikit/hooks/useUiKitActionManager';
jest.mock('@rocket.chat/ui-contexts', () => ({
useRouteParameter: jest.fn((param: string) => {
if (param === 'context') return 'test-context';
if (param === 'tab') return 'app';
return undefined;
}),
useRoomToolbox: jest.fn(),
}));
jest.mock('../../../uikit/hooks/useUiKitActionManager', () => ({
useUiKitActionManager: jest.fn(),
}));
const mockGetInteractionPayloadByViewId = jest.fn();
const mockOn = jest.fn();
const mockOff = jest.fn();
const mockCloseTab = jest.fn();
beforeEach(() => {
(useUiKitActionManager as jest.Mock).mockReturnValue({
getInteractionPayloadByViewId: mockGetInteractionPayloadByViewId,
on: mockOn,
off: mockOff,
});
(useRoomToolbox as jest.Mock).mockReturnValue({ closeTab: mockCloseTab });
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return the view when present', () => {
const view = { id: 'view1' };
mockGetInteractionPayloadByViewId.mockReturnValue({ view });
const { result } = renderHook(() => useAppsContextualBar());
expect(result.current).toBe(view);
});
it('should call closeTab if view is not present', () => {
mockGetInteractionPayloadByViewId.mockReturnValue(undefined);
renderHook(() => useAppsContextualBar());
expect(mockCloseTab).toHaveBeenCalled();
});

View File

@@ -0,0 +1,43 @@
import { useRouteParameter, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useCallback, useSyncExternalStore } from 'react';
import { useUiKitActionManager } from '../../../uikit/hooks/useUiKitActionManager';
export const useAppsContextualBar = () => {
const context = useRouteParameter('context');
const actionManager = useUiKitActionManager();
const tab = useRouteParameter('tab');
const { closeTab } = useRoomToolbox();
const getSnapshot = useCallback(() => {
if (tab !== 'app' || !context) {
return undefined;
}
return actionManager.getInteractionPayloadByViewId(context)?.view;
}, [actionManager, context, tab]);
const subscribe = useCallback(
(handler: () => void) => {
if (tab !== 'app' || !context) {
return () => undefined;
}
const view = actionManager.getInteractionPayloadByViewId(context)?.view;
if (!view) {
closeTab();
return () => undefined;
}
actionManager.on(context, handler);
return () => actionManager.off(context, handler);
},
[actionManager, closeTab, tab, context],
);
const view = useSyncExternalStore(subscribe, getSnapshot);
return view;
};

View File

@@ -0,0 +1,96 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor, act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useBanUser } from './useBanUser';
import { createFakeRoom } from '../../../../tests/mocks/data';
jest.mock('../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomName: () => 'test-room',
},
}));
const fakeRoom = createFakeRoom({ t: 'c', name: 'test-room' });
const appRoot = (room: IRoom | undefined = fakeRoom) =>
mockAppRoot()
.withRoom(room)
.withEndpoint('POST', '/v1/rooms.banUser', () => null)
.withTranslations('en', 'core', {
Are_you_sure: 'Are you sure?',
Yes_ban_user: 'Yes, ban user',
The_user_will_be_banned_from__roomName__: 'The user will be banned from {{roomName}}',
});
describe('useBanUser', () => {
it('should return a function', () => {
const { result } = renderHook(() => useBanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
expect(result.current).toEqual(expect.any(Function));
});
it('should open a confirmation modal when called', () => {
const { result } = renderHook(() => useBanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Yes, ban user' })).toBeInTheDocument();
});
it('should call the ban endpoint when confirmed', async () => {
const banEndpoint = jest.fn(() => null);
const { result } = renderHook(() => useBanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().withEndpoint('POST', '/v1/rooms.banUser', banEndpoint).build(),
});
act(() => result.current('testuser'));
await userEvent.click(screen.getByRole('button', { name: 'Yes, ban user' }));
await waitFor(() => expect(banEndpoint).toHaveBeenCalledWith({ roomId: fakeRoom._id, username: 'testuser' }));
});
it('should close the modal after mutation settles', async () => {
const { result } = renderHook(() => useBanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Yes, ban user' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Are you sure?' })).not.toBeInTheDocument());
});
it('should close the modal when cancel is clicked', async () => {
const { result } = renderHook(() => useBanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Are you sure?' })).not.toBeInTheDocument());
});
it('should throw if room is not found', () => {
expect(() =>
renderHook(() => useBanUser({ roomId: 'nonexistent' }), {
wrapper: appRoot(undefined)
.withRoom(undefined as unknown as IRoom)
.build(),
}),
).toThrow('error-invalid-room');
});
});

View File

@@ -0,0 +1,63 @@
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useUserRoom, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { roomsQueryKeys } from '../../../lib/queryKeys';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
type UseBanUserProps = {
roomId: string;
};
export const useBanUser = ({ roomId }: UseBanUserProps) => {
const { t } = useTranslation();
const room = useUserRoom(roomId);
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const banUserEndpoint = useEndpoint('POST', '/v1/rooms.banUser');
if (!room) {
throw new Error('error-invalid-room');
}
const roomName = escapeHTML(roomCoordinator.getRoomName(room.t, room));
const { mutate: banUser } = useMutation({
mutationFn: ({ roomId, username }: { roomId: string; username: string }) => {
return banUserEndpoint({ roomId, username });
},
onSuccess: (_, { username }) => {
dispatchToastMessage({ type: 'success', message: t('User__username__banned_from__roomName__', { username, roomName }) });
void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.members(room._id, room.t) });
void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.bannedUsers(room._id) });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
setModal(null);
},
});
return useCallback(
(username: string) => {
setModal(
<GenericModal
variant='danger'
title={t('Are_you_sure')}
confirmText={t('Yes_ban_user')}
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
onConfirm={() => banUser({ roomId: room._id, username })}
>
{t('The_user_will_be_banned_from__roomName__', { roomName })}
</GenericModal>,
);
},
[setModal, t, roomName, room._id, banUser],
);
};

View File

@@ -0,0 +1,188 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { useDebouncedCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import type { CSSProperties, MutableRefObject } from 'react';
import { useRef, useState } from 'react';
import { useDateListController } from '../providers/DateListProvider';
type useDateScrollReturn = {
handleDateScroll: (topMessage: IMessage | undefined, offset: number) => void;
bubbleRef: MutableRefObject<HTMLElement | null>;
listStyle?: ReturnType<typeof css>;
} & BubbleDateProps;
export type BubbleDateProps = {
bubbleDate: string | undefined;
bubbleDateClassName?: ReturnType<typeof css>;
showBubble: boolean;
bubbleDateStyle?: CSSProperties;
};
// The threshold in pixels to consider a date divider as "visible" when scrolling.
// The divider being a few pixels above the top of the viewport is safe, as it is always contained inside a message
const DATE_DIVIDER_VISIBILITY_THRESHOLD = 100;
type Matched = [date: string, divider: HTMLElement | undefined, style: { [key: string]: string | number }, showDivider: boolean] | [];
export const useDateScroll = (margin = 8): useDateScrollReturn => {
const [bubbleDate, setBubbleDate] = useSafely(
useState<{
date: string;
show: boolean;
style?: CSSProperties;
bubbleDateClassName?: ReturnType<typeof css>;
offset: number;
showDivider: boolean;
}>({
date: '',
show: false,
style: undefined,
bubbleDateClassName: undefined,
offset: 0,
showDivider: true,
}),
);
const { list } = useDateListController();
const bubbleRef = useRef<HTMLElement>(null);
const hideBubbleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDateScroll = useDebouncedCallback(
(topMessage: IMessage | undefined, offset: number) => {
if (hideBubbleTimeoutRef.current) {
clearTimeout(hideBubbleTimeoutRef.current);
hideBubbleTimeoutRef.current = null;
}
const bubbleBottom = bubbleRef.current?.getBoundingClientRect().bottom || 0;
// Gets the first non visible message date and sets the bubble date to it
let matched: Matched = [...list].reduce<Matched>((ret, divider) => {
const { top: dividerTop, height: dividerHeight } = divider.getBoundingClientRect();
// Some dividers might be kept in the DOM if the "new day" message has a file attached
// So we check if they are actually visible to avoid showing old dates in the bubble
// We also need the parent since it has the actual offset inside the scroll container
const parentOffsetTop = divider.parentElement?.offsetTop;
const parentSafeOffset = parentOffsetTop !== undefined ? parentOffsetTop + DATE_DIVIDER_VISIBILITY_THRESHOLD : 0;
// Sanitize elements
if (!divider.dataset.id || parentSafeOffset < offset) {
return ret;
}
const { id } = divider.dataset;
// if the bubble if between the divider and the top, position it at the top of the divider
if (dividerTop > bubbleBottom && dividerTop < bubbleBottom + dividerHeight) {
// if there's no previous date it means the previous divider is not mounted anymore, so we use the message date or fallback to the divider date as a last resort
const date = ret[0] ? ret[0] : (topMessage && new Date(topMessage.ts).toISOString()) || new Date(id).toISOString();
return [
date,
ret[1] || divider,
{
position: 'absolute',
top: `${dividerTop - dividerHeight - bubbleBottom + margin}px`,
left: ' 50%',
translate: '-50%',
zIndex: 11,
},
true,
];
}
if (dividerTop < bubbleBottom + dividerHeight) {
return [
new Date(id).toISOString(),
divider,
{
position: 'absolute',
top: `${margin}px`,
left: ' 50%',
translate: '-50%',
zIndex: 11,
},
false,
];
}
return ret;
}, []);
// Fallback: no divider rendered near the top (virtualized out) → use the top visible message's date, pinned
if (matched.length === 0 && topMessage) {
matched = [
new Date(topMessage.ts).toISOString(),
undefined,
{
position: 'absolute',
top: `${margin}px`,
left: ' 50%',
translate: '-50%',
zIndex: 11,
},
true,
];
}
const [date, divider, style, showDivider] = matched;
// We always keep the previous date if we don't have a new one, so when the bubble disappears it doesn't flicker
setBubbleDate((current) => ({
...current,
date: '',
...(date && { date }),
show: Boolean(date),
style,
showDivider: showDivider ?? true,
bubbleDateClassName: css`
opacity: 0;
transition: opacity 0.6s;
&.bubble-visible {
opacity: 1;
}
`,
}));
if (divider) {
const { top } = divider.getBoundingClientRect();
if (top < bubbleBottom && top > 0) {
return;
}
}
hideBubbleTimeoutRef.current = setTimeout(
() =>
setBubbleDate((current) => ({
...current,
show: false,
showDivider: true,
})),
1000,
);
},
5,
[list, margin, setBubbleDate],
);
// FIXME: This should be handled at the component level
const listStyle =
bubbleDate.show && bubbleDate.date && !bubbleDate.showDivider
? css`
position: relative;
& [data-time='${bubbleDate.date.replaceAll(/[-T:.]/g, '').substring(0, 8)}'] {
opacity: 0;
}
`
: undefined;
return {
handleDateScroll,
bubbleRef,
listStyle,
bubbleDate: bubbleDate.date,
bubbleDateStyle: bubbleDate.style,
showBubble: Boolean(bubbleDate.show),
bubbleDateClassName: bubbleDate.bubbleDateClassName,
};
};

View File

@@ -0,0 +1,110 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useE2EEResetRoomKey } from './useE2EEResetRoomKey';
import { e2e } from '../../../lib/e2ee';
jest.mock('../../../lib/e2ee', () => ({
e2e: {
getInstanceByRoomId: jest.fn(),
},
}));
describe('useE2EEResetRoomKey', () => {
const e2eResetRoomKeyMock = jest.fn().mockResolvedValue({
e2eKeyId: 'E2E_KEY_ID',
e2eKey: 'E2E_KEY',
});
const resetRoomKeyMock = jest.fn();
const roomId = 'ROOM_ID';
afterEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
(e2e.getInstanceByRoomId as jest.Mock).mockImplementation(() => ({
resetRoomKey: e2eResetRoomKeyMock,
}));
});
it('should call resetRoomKey endpoint with correct params', async () => {
const { result } = renderHook(() => useE2EEResetRoomKey(), {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(),
});
await waitFor(() => result.current.mutate({ roomId }));
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1);
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID');
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1);
expect(resetRoomKeyMock).toHaveBeenCalledWith({
rid: roomId,
e2eKeyId: 'E2E_KEY_ID',
e2eKey: 'E2E_KEY',
});
await waitFor(() => expect(result.current.status).toBe('success'));
});
it('should return an errror if e2e.getInstanceByRoomId() does not return correct params', async () => {
(e2e.getInstanceByRoomId as jest.Mock).mockReturnValue(null);
const { result } = renderHook(() => useE2EEResetRoomKey(), {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(),
});
await waitFor(() => result.current.mutate({ roomId }));
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1);
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID');
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(0);
await waitFor(() => expect(result.current.status).toBe('error'));
});
it('should return an errror if e2e.resetRoomKey() does not return correct params', async () => {
const e2eResetRoomKeyMock = jest.fn().mockResolvedValue(null);
const roomId = 'ROOM_ID';
(e2e.getInstanceByRoomId as jest.Mock).mockImplementation(() => ({
resetRoomKey: e2eResetRoomKeyMock,
}));
const { result } = renderHook(() => useE2EEResetRoomKey(), {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(),
});
await waitFor(() => result.current.mutate({ roomId }));
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1);
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID');
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1);
expect(resetRoomKeyMock).toHaveBeenCalledTimes(0);
await waitFor(() => expect(result.current.status).toBe('error'));
});
it('should return an error if resetRoomKey does not resolve', async () => {
resetRoomKeyMock.mockRejectedValue(new Error('error-e2e-key-reset-in-progress'));
const { result } = renderHook(() => useE2EEResetRoomKey(), {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(),
});
await waitFor(() => result.current.mutate({ roomId }));
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1);
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID');
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1);
expect(resetRoomKeyMock).toHaveBeenCalledWith({
rid: roomId,
e2eKeyId: 'E2E_KEY_ID',
e2eKey: 'E2E_KEY',
});
await waitFor(() => expect(result.current.status).toBe('error'));
});
});

View File

@@ -0,0 +1,39 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { e2e } from '../../../lib/e2ee';
type UseE2EEResetRoomKeyVariables = {
roomId: IRoom['_id'];
};
export const useE2EEResetRoomKey = (
options?: Omit<UseMutationOptions<void, Error, UseE2EEResetRoomKeyVariables>, 'mutationFn'>,
): UseMutationResult<void, Error, UseE2EEResetRoomKeyVariables> => {
const resetRoomKey = useEndpoint('POST', '/v1/e2e.resetRoomKey');
return useMutation({
mutationFn: async ({ roomId }) => {
const e2eRoom = await e2e.getInstanceByRoomId(roomId);
if (!e2eRoom) {
throw new Error('Cannot reset room key');
}
const { e2eKey, e2eKeyId } = (await e2eRoom.resetRoomKey()) ?? {};
if (!e2eKey || !e2eKeyId) {
throw new Error('Cannot reset room key');
}
try {
await resetRoomKey({ rid: roomId, e2eKeyId, e2eKey });
} catch (error) {
throw error;
}
},
...options,
});
};

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { e2e } from '../../../lib/e2ee';
export const useE2EERoom = (rid: string) => {
const { data } = useQuery({
queryKey: ['e2eRoom', rid],
queryFn: () => e2e.getInstanceByRoomId(rid),
});
return data;
};

View File

@@ -0,0 +1,19 @@
import { useMemo, useSyncExternalStore } from 'react';
import { useE2EERoom } from './useE2EERoom';
import type { E2ERoomState } from '../../../lib/e2ee/E2ERoomState';
export const useE2EERoomState = (rid: string) => {
const e2eRoom = useE2EERoom(rid);
const subscribeE2EERoomState = useMemo(
() =>
[
(callback: () => void): (() => void) => (e2eRoom ? e2eRoom.onStateChange(callback) : () => undefined),
(): E2ERoomState | undefined => (e2eRoom ? e2eRoom.getState() : undefined),
] as const,
[e2eRoom],
);
return useSyncExternalStore(...subscribeE2EERoomState);
};

View File

@@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'react';
import { e2e } from '../../../lib/e2ee';
import type { E2EEState } from '../../../lib/e2ee/E2EEState';
const subscribe = (callback: () => void): (() => void) => e2e.on('E2E_STATE_CHANGED', callback);
const getSnapshot = (): E2EEState => e2e.getState();
export const useE2EEState = (): E2EEState => useSyncExternalStore(subscribe, getSnapshot);

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
export const useFileAsDataURL = (file: File): [loaded: boolean, url: null | FileReader['result']] => {
const [loaded, setLoaded] = useState(false);
const [url, setUrl] = useState<FileReader['result']>(null);
useEffect(() => {
setLoaded(false);
setUrl(null);
let shouldIgnore = false;
const reader = new FileReader();
reader.onload = (e): void => {
if (shouldIgnore) {
return;
}
setUrl(e?.target?.result || null);
setLoaded(true);
};
reader.onerror = (): void => {
if (shouldIgnore) {
return;
}
setUrl(null);
setLoaded(true);
};
reader.readAsDataURL(file);
return () => {
shouldIgnore = true;
if (reader.readyState === FileReader.LOADING) {
reader.abort();
}
};
}, [file]);
return [loaded, url];
};

View File

@@ -0,0 +1,13 @@
import { useSyncExternalStore } from 'react';
import { useChat } from '../contexts/ChatContext';
export const useFirstUnreadMessageId = (): string | undefined => {
const chat = useChat();
if (!chat) {
throw new Error('useFirstUnreadMessageId must be used within a ChatContextProvider');
}
return useSyncExternalStore(chat.readStateManager.onUnreadStateChange, chat.readStateManager.getFirstUnreadRecordId);
};

View File

@@ -0,0 +1,39 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
import { Subscriptions } from '../../../stores';
type GoToRoomByIdOptions = {
replace?: boolean;
routeParamsOverrides?: Record<string, string>;
};
export const useGoToRoom = (): ((roomId: IRoom['_id'], options?: GoToRoomByIdOptions) => Promise<void>) => {
const router = useRouter();
const getRoomInfo = useEndpoint('GET', '/v1/rooms.info');
const dispatchToastMessage = useToastMessageDispatch();
// TODO: remove params recycling
return useStableCallback(async (roomId: IRoom['_id'], options?: GoToRoomByIdOptions) => {
if (!roomId) return;
const subscription: ISubscription | undefined = Subscriptions.state.find((record) => record.rid === roomId);
if (subscription) {
roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters(), options);
return;
}
try {
const { room } = await getRoomInfo({ roomId });
if (!room) {
throw new Error('Room not found');
}
roomCoordinator.openRouteLink(room.t, { rid: room._id, ...room }, router.getSearchParameters(), options);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
};

View File

@@ -0,0 +1,25 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRouter } from '@rocket.chat/ui-contexts';
export const useGoToThread = ({ replace = false }: { replace?: boolean } = {}) => {
const router = useRouter();
// TODO: remove params recycling
return useStableCallback(({ rid, tmid, msg }: { rid: IRoom['_id']; tmid: IMessage['_id']; msg?: IMessage['_id'] }) => {
const routeName = router.getRouteName();
if (!routeName) {
throw new Error('Route name is not defined');
}
router.navigate(
{
name: routeName,
params: { rid, ...router.getRouteParameters(), tab: 'thread', context: tmid },
search: { ...router.getSearchParameters(), ...(msg && { msg }) },
},
{ replace },
);
});
};

View File

@@ -0,0 +1,28 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useRoom } from '../contexts/RoomContext';
export const useGoToThreadList = ({ replace = false }: { replace?: boolean } = {}): (() => void) => {
const router = useRouter();
const room = useRoom();
return useStableCallback(() => {
const routeName = router.getRouteName();
if (!routeName) {
throw new Error('Route name is not defined');
}
const { context, ...params } = router.getRouteParameters();
router.navigate(
{
name: routeName,
params: { rid: room._id, ...params, tab: 'thread' },
search: router.getSearchParameters(),
},
{ replace },
);
});
};

View File

@@ -0,0 +1,27 @@
import { useDebouncedState, useSafely, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { useCallback } from 'react';
export const useIsVisible = () => {
const [menuVisibility, setMenuVisibility] = useSafely(useDebouncedState(!!window.DISABLE_ANIMATION, 100));
const callbackRef = useSafeRefCallback(
useCallback(
(node: HTMLElement) => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
setMenuVisibility(entry.isIntersecting);
});
});
observer.observe(node);
return () => {
observer.disconnect();
};
},
[setMenuVisibility],
),
);
return [callbackRef, menuVisibility] as const;
};

View File

@@ -0,0 +1,116 @@
import { createFocusManager, useFocusManager } from '@react-aria/focus';
import type { RefCallback } from 'react';
import { useCallback } from 'react';
const isListItem = (node: EventTarget) =>
(node as HTMLElement).getAttribute('role') === 'listitem' || (node as HTMLElement).getAttribute('role') === 'link';
const isMessageToolbarAction = (node: EventTarget) => (node as HTMLElement).parentElement?.getAttribute('role') === 'toolbar';
const isSystemMessage = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-message-system');
const isThreadMessage = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-message-thread');
/**
* Custom hook to provide the room navigation by keyboard.
* @param ref - A ref to the message list DOM element.
*/
export const useMessageListNavigation = (): { messageListRef: RefCallback<HTMLElement> } => {
const roomFocusManager = useFocusManager();
const messageListRef = useCallback(
(node: HTMLElement | null) => {
let lastMessageFocused: HTMLElement | null = null;
let initialFocus = true;
if (!node) {
return;
}
const massageListFocusManager = createFocusManager({
current: node,
});
node.addEventListener('keydown', (e) => {
if (!e.target) {
return;
}
if (!isListItem(e.target)) {
return;
}
if (e.key === 'Tab') {
if (e.shiftKey) {
e.preventDefault();
e.stopPropagation();
roomFocusManager?.focusFirst({
from: document.getElementsByClassName('rcx-room-header')[0],
});
} else if (isThreadMessage(e.target) || isSystemMessage(e.target) || isMessageToolbarAction(e.target)) {
e.preventDefault();
e.stopPropagation();
roomFocusManager?.focusNext({
accept: (node) => node.tagName === 'TEXTAREA',
});
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (e.key === 'ArrowUp') {
massageListFocusManager.focusPrevious({ accept: (node) => isListItem(node) });
}
if (e.key === 'ArrowDown') {
massageListFocusManager.focusNext({ accept: (node) => isListItem(node) });
}
lastMessageFocused = document.activeElement as HTMLElement;
}
});
node.addEventListener(
'blur',
(e) => {
if (
!(e.relatedTarget as HTMLElement)?.matches(':focus-visible') ||
!(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)
) {
return;
}
if (!e.currentTarget.contains(e.relatedTarget) && !lastMessageFocused) {
lastMessageFocused = e.target as HTMLElement;
}
},
{ capture: true },
);
node.addEventListener(
'focus',
(e) => {
const triggeredByKeyboard = (e.target as HTMLElement)?.matches(':focus-visible');
if (!triggeredByKeyboard || !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)) {
return;
}
if (initialFocus) {
massageListFocusManager.focusLast({ accept: (node) => isListItem(node) });
lastMessageFocused = document.activeElement as HTMLElement;
initialFocus = false;
return;
}
if (lastMessageFocused && !e.currentTarget.contains(e.relatedTarget) && node.contains(e.target as HTMLElement)) {
lastMessageFocused?.focus();
}
},
{ capture: true },
);
},
[roomFocusManager],
);
return {
messageListRef,
};
};

View File

@@ -0,0 +1,136 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useOpenRoom } from './useOpenRoom';
import { createFakeRoom, createFakeSubscription } from '../../../../tests/mocks/data';
import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError';
import { Rooms, Subscriptions } from '../../../stores';
jest.mock('../../../lib/LegacyRoomManager', () => ({
LegacyRoomManager: { open: jest.fn(), close: jest.fn() },
}));
jest.mock('../../../lib/RoomManager', () => ({
RoomManager: { opened: null },
}));
jest.mock('./useOpenRoomMutation', () => ({
useOpenRoomMutation: () => ({ mutateAsync: jest.fn() }),
}));
afterEach(() => {
Subscriptions.state.replaceAll([]);
Rooms.state.replaceAll([]);
});
describe('useOpenRoom', () => {
describe('type-aware subscription cache lookup', () => {
it('resolves /channel/<name> to the channel even when a DM with the same name is cached', async () => {
const channelRid = 'channel-rid-abc';
const dmRid = 'dm-rid-abc';
const sharedName = 'alex';
// Seed cache: only a DM subscription named 'alex' — no channel subscription
Subscriptions.state.store(createFakeSubscription({ t: 'd', name: sharedName, rid: dmRid, open: true }));
Rooms.state.store(createFakeRoom({ _id: dmRid, t: 'd' }));
const channelRoom = createFakeRoom({ _id: channelRid, t: 'c', name: sharedName });
const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: sharedName }), {
wrapper: mockAppRoot()
.withJohnDoe()
.withPermission('preview-c-room')
.withMethod('getRoomByTypeAndName', () => channelRoom as any)
.build(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.rid).toBe(channelRid);
});
it('resolves from cache when a channel subscription with a matching name and type is already cached', async () => {
const channelRid = 'channel-rid-general';
const channelName = 'general';
Subscriptions.state.store(createFakeSubscription({ t: 'c', name: channelName, rid: channelRid, open: true }));
Rooms.state.store(createFakeRoom({ _id: channelRid, t: 'c', name: channelName }));
const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: channelName }), {
wrapper: mockAppRoot().withJohnDoe().build(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.rid).toBe(channelRid);
});
it('resolves a DM from cache when navigating by rid', async () => {
const dmRid = 'dm-rid-xyz';
Subscriptions.state.store(createFakeSubscription({ t: 'd', name: 'bob', rid: dmRid, open: true }));
Rooms.state.store(createFakeRoom({ _id: dmRid, t: 'd' }));
const { result } = renderHook(() => useOpenRoom({ type: 'd', reference: dmRid }), {
wrapper: mockAppRoot().withJohnDoe().build(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.rid).toBe(dmRid);
});
});
describe('error classification', () => {
it('maps error-no-permission to RoomNotFoundError without retrying (regression for #40991)', async () => {
const getRoomByTypeAndName = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('No permission'), { error: 'error-no-permission' });
});
const { result } = renderHook(() => useOpenRoom({ type: 'p', reference: 'private-channel' }), {
wrapper: mockAppRoot().withJohnDoe().withMethod('getRoomByTypeAndName', getRoomByTypeAndName).build(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(RoomNotFoundError);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(1);
});
it('maps error-invalid-room to RoomNotFoundError without retrying for non-DM rooms', async () => {
const getRoomByTypeAndName = jest.fn().mockImplementation(() => {
throw Object.assign(new Error('Invalid room'), { error: 'error-invalid-room' });
});
const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: 'missing-channel' }), {
wrapper: mockAppRoot().withJohnDoe().withMethod('getRoomByTypeAndName', getRoomByTypeAndName).build(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(RoomNotFoundError);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(1);
});
it('retries unclassified transient errors and recovers on a later attempt', async () => {
const channelRid = 'channel-rid-transient';
const channelName = 'flaky-channel';
const channelRoom = createFakeRoom({ _id: channelRid, t: 'c', name: channelName });
const getRoomByTypeAndName = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('network down');
})
.mockImplementation(() => channelRoom);
const { result } = renderHook(() => useOpenRoom({ type: 'c', reference: channelName }), {
wrapper: mockAppRoot()
.withJohnDoe()
.withPermission('preview-c-room')
.withMethod('getRoomByTypeAndName', getRoomByTypeAndName)
.build(),
});
// Backoff is Math.min(1000 * 2 ** attempt, 5000); first retry fires after ~1s.
await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 });
expect(result.current.data?.rid).toBe(channelRid);
expect(getRoomByTypeAndName).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,178 @@
import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings';
import { getObjectKeys } from '@rocket.chat/tools';
import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect } from 'react';
import { useOpenRoomMutation } from './useOpenRoomMutation';
import { roomFields } from '../../../../lib/publishFields';
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
import { RoomManager } from '../../../lib/RoomManager';
import { NotAuthorizedError } from '../../../lib/errors/NotAuthorizedError';
import { NotSubscribedToRoomError } from '../../../lib/errors/NotSubscribedToRoomError';
import { OldUrlRoomError } from '../../../lib/errors/OldUrlRoomError';
import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError';
import { roomsQueryKeys } from '../../../lib/queryKeys';
import { Rooms, Subscriptions } from '../../../stores';
export function useOpenRoom({ type, reference }: { type: RoomType; reference: string }) {
const user = useUser();
const hasPreviewPermission = usePermission('preview-c-room');
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', true);
const getRoomByTypeAndName = useMethod('getRoomByTypeAndName');
const createDirectMessage = useEndpoint('POST', '/v1/im.create');
const directRoute = useRoute('direct');
const openRoom = useOpenRoomMutation();
// Try to resolve the reference to a known rid using locally cached subscriptions and rooms.
// Returns null when the cache can't safely answer (anonymous user, public preview, DM redirect
// from a username URL, missing record). The caller still validates side effects.
const tryCacheShortcut = useCallback((): { rid: IRoom['_id'] } | undefined => {
if (!user?._id || !reference || !type) {
return undefined;
}
const sub = Subscriptions.state.find((record) => record.t === type && (record.rid === reference || record.name === reference));
if (!sub) {
return undefined;
}
const room = Rooms.state.get(sub.rid);
if (!room) {
return undefined;
}
// DM URLs that still reference a username (rather than the rid) need the redirect path in
// queryFn — let the server resolve.
if (type === 'd' && reference !== sub.rid) {
return undefined;
}
// Skip when the user hasn't actually opened the subscription yet; queryFn will call
// openRoom.mutateAsync to flip sub.open.
if (sub.open === false) {
return undefined;
}
return { rid: sub.rid };
}, [reference, type, user?._id]);
const result = useQuery({
// we need to add uid and username here because `user` is not loaded all at once (see UserProvider -> Meteor.user())
queryKey: roomsQueryKeys.roomReference(reference, type, user?._id, user?.username),
// Render immediately from local cache when we already know the rid; queryFn still runs in
// the background to revalidate permissions / fetch fresh room fields.
placeholderData: tryCacheShortcut,
queryFn: async (): Promise<{ rid: IRoom['_id'] }> => {
const cached = tryCacheShortcut();
if (cached) {
LegacyRoomManager.open({ typeName: type + reference, rid: cached.rid });
return cached;
}
if ((user && !user.username) || (!user && !allowAnonymousRead)) {
throw new NotAuthorizedError();
}
if (!reference || !type) {
throw new RoomNotFoundError(undefined, { type, reference });
}
let roomData: IRoom;
try {
roomData = await getRoomByTypeAndName(type, reference);
} catch (error) {
const errorCode = error && typeof error === 'object' && 'error' in error ? error.error : undefined;
// "No permission" means the room exists but the user can't see it — surface the
// not-found/no-access screen rather than retrying it as a transient failure.
if (errorCode === 'error-no-permission') {
throw new RoomNotFoundError(undefined, { type, reference });
}
if (errorCode !== 'error-invalid-room') {
throw error;
}
if (type !== 'd') {
throw new RoomNotFoundError(undefined, { type, reference });
}
try {
const { room } = await createDirectMessage({ usernames: reference });
directRoute.push({ rid: room._id }, (prev) => prev);
} catch (error) {
throw new RoomNotFoundError(undefined, { type, reference });
}
throw new OldUrlRoomError(undefined, { type, reference });
}
if (!roomData._id) {
throw new RoomNotFoundError(undefined, { type, reference });
}
const unsetKeys = getObjectKeys(roomData).filter((key) => !(key in roomFields));
unsetKeys.forEach((key) => {
delete roomData[key];
});
Rooms.state.store(roomData);
const room = Rooms.state.get(roomData._id);
if (!room) {
throw new TypeError('room is undefined');
}
const sub = Subscriptions.state.find((record) => record.t === type && (record.rid === reference || record.name === reference));
if (reference !== undefined && room._id !== reference && type === 'd') {
// Redirect old url using username to rid
LegacyRoomManager.close(type + reference);
directRoute.push({ rid: room._id }, (prev) => prev);
throw new OldUrlRoomError(undefined, { rid: room._id });
}
// if user doesn't exist at this point, anonymous read is enabled, otherwise an error would have been thrown
if (user && !sub && !hasPreviewPermission && isPublicRoom(room)) {
throw new NotSubscribedToRoomError(undefined, { rid: room._id });
}
LegacyRoomManager.open({ typeName: type + reference, rid: room._id });
if (room._id === RoomManager.opened) {
return { rid: room._id };
}
// update user's room subscription
if (!!user?._id && sub && !sub.open) {
await openRoom.mutateAsync({ roomId: room._id, userId: user._id });
}
return { rid: room._id };
},
retry: (failureCount, error) => {
const unrecoverableErrors = [RoomNotFoundError, OldUrlRoomError, NotAuthorizedError, NotSubscribedToRoomError];
if (unrecoverableErrors.some((e) => error instanceof e)) {
return false;
}
return failureCount < 4;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
});
const queryClient = useQueryClient();
const { error } = result;
useEffect(() => {
if (error) {
if (type === 'l' && error instanceof RoomNotFoundError) {
Rooms.state.remove((record) => Object.values(record).includes(reference));
queryClient.removeQueries({ queryKey: ['rooms', reference] });
queryClient.removeQueries({ queryKey: roomsQueryKeys.info(reference) });
}
}
}, [error, queryClient, reference, type]);
return result;
}

View File

@@ -0,0 +1,32 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { updateSubscription } from '../../../lib/mutationEffects/updateSubscription';
type OpenRoomParams = {
roomId: string;
userId: string;
};
export const useOpenRoomMutation = () => {
const openRoom = useEndpoint('POST', '/v1/rooms.open');
return useMutation({
mutationFn: async ({ roomId, userId }: OpenRoomParams) => {
await openRoom({ roomId });
return { userId, roomId };
},
onMutate: async ({ roomId, userId }) => {
return updateSubscription(roomId, userId, { open: true });
},
onError: async (_, { roomId, userId }, rollbackDocument) => {
if (!rollbackDocument) {
return;
}
const { open } = rollbackDocument;
updateSubscription(roomId, userId, { open });
},
});
};

View File

@@ -0,0 +1,80 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useRetentionPolicy } from './useRetentionPolicy';
import { createFakeRoom } from '../../../../tests/mocks/data';
const getGlobalSettings = ({
enabled = false,
filesOnly = false,
doNotPrunePinned = false,
ignoreThreads = false,
appliesToChannels = false,
appliesToGroups = false,
appliesToDMs = false,
}) => {
return mockAppRoot()
.withSetting('RetentionPolicy_Enabled', enabled)
.withSetting('RetentionPolicy_FilesOnly', filesOnly)
.withSetting('RetentionPolicy_DoNotPrunePinned', doNotPrunePinned)
.withSetting('RetentionPolicy_DoNotPruneThreads', ignoreThreads)
.withSetting('RetentionPolicy_AppliesToChannels', appliesToChannels)
.withSetting('RetentionPolicy_AppliesToGroups', appliesToGroups)
.withSetting('RetentionPolicy_AppliesToDMs', appliesToDMs);
};
const defaultValue = {
enabled: false,
isActive: false,
filesOnly: false,
excludePinned: false,
ignoreThreads: false,
};
const roomTypeConfig = {
c: { appliesToChannels: true },
p: { appliesToGroups: true },
d: { appliesToDMs: true },
};
const CHANNELS_TYPE = 'c';
it('should return the default value if global retention is not enabled', async () => {
const fakeRoom = createFakeRoom({ t: CHANNELS_TYPE });
const { result } = renderHook(() => useRetentionPolicy(fakeRoom), {
wrapper: getGlobalSettings({}).build(),
});
expect(result.current).toEqual(expect.objectContaining(defaultValue));
});
it('should return enabled true if global retention is enabled', async () => {
const fakeRoom = createFakeRoom({ t: CHANNELS_TYPE });
const { result } = renderHook(() => useRetentionPolicy(fakeRoom), {
wrapper: getGlobalSettings({ enabled: true }).build(),
});
expect(result.current).toEqual(expect.objectContaining({ ...defaultValue, enabled: true }));
});
it('should return enabled and active true global retention is active for rooms of the type', async () => {
const fakeRoom = createFakeRoom({ t: CHANNELS_TYPE });
const { result } = renderHook(() => useRetentionPolicy(fakeRoom), {
wrapper: getGlobalSettings({ enabled: true, ...roomTypeConfig[CHANNELS_TYPE] }).build(),
});
expect(result.current).toEqual(expect.objectContaining({ ...defaultValue, enabled: true, isActive: true }));
});
it('should isActive be false if global retention is active for rooms of the type and room has retention.enabled false', async () => {
const fakeRoom = createFakeRoom({ t: CHANNELS_TYPE, retention: { enabled: false } });
const { result } = renderHook(() => useRetentionPolicy(fakeRoom), {
wrapper: getGlobalSettings({ enabled: true, ...roomTypeConfig[CHANNELS_TYPE] }).build(),
});
expect(result.current?.isActive).toBe(false);
});

View File

@@ -0,0 +1,122 @@
import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { useSetting } from '@rocket.chat/ui-contexts';
import { TIMEUNIT, isValidTimespan, timeUnitToMs } from '../../../lib/convertTimeUnit';
const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy =>
'retention' in room && room.retention !== undefined;
const isRetentionOverridden = (room: IRoom & { retention?: any }) => 'overrideGlobal' in room.retention && room.retention.overrideGlobal;
type RetentionPolicySettings = {
enabled: boolean;
filesOnly: boolean;
doNotPrunePinned: boolean;
ignoreThreads: boolean;
appliesToChannels: boolean;
maxAgeChannels: number;
appliesToGroups: boolean;
maxAgeGroups: number;
appliesToDMs: boolean;
maxAgeDMs: number;
};
const isActive = (room: IRoom, { enabled, appliesToChannels, appliesToGroups, appliesToDMs }: RetentionPolicySettings): boolean => {
if (!enabled) {
return false;
}
if (hasRetentionPolicy(room) && room.retention.enabled !== undefined) {
return room.retention.enabled;
}
switch (room.t) {
case 'c':
return appliesToChannels;
case 'p':
return appliesToGroups;
case 'd':
return appliesToDMs;
}
return false;
};
const extractFilesOnly = (room: IRoom, { filesOnly }: RetentionPolicySettings): boolean => {
if (hasRetentionPolicy(room) && isRetentionOverridden(room)) {
return room.retention.filesOnly;
}
return filesOnly;
};
const extractExcludePinned = (room: IRoom, { doNotPrunePinned }: RetentionPolicySettings): boolean => {
if (hasRetentionPolicy(room) && isRetentionOverridden(room)) {
return room.retention.excludePinned;
}
return doNotPrunePinned;
};
const extractIgnoreThreads = (room: IRoom, { ignoreThreads }: RetentionPolicySettings): boolean => {
if (hasRetentionPolicy(room) && isRetentionOverridden(room)) {
return room.retention.ignoreThreads;
}
return ignoreThreads;
};
const getMaxAge = (room: IRoom, { maxAgeChannels, maxAgeGroups, maxAgeDMs }: RetentionPolicySettings): number => {
if (hasRetentionPolicy(room) && isRetentionOverridden(room) && isValidTimespan(room.retention.maxAge)) {
return timeUnitToMs(TIMEUNIT.days, room.retention.maxAge);
}
if (room.t === 'c') {
return maxAgeChannels;
}
if (room.t === 'p') {
return maxAgeGroups;
}
if (room.t === 'd') {
return maxAgeDMs;
}
return -Infinity;
};
export type RetentionPolicy = {
enabled: boolean;
isActive: boolean;
filesOnly: boolean;
excludePinned: boolean;
ignoreThreads: boolean;
maxAge: number;
};
export const useRetentionPolicy = (room: IRoom | undefined): RetentionPolicy | undefined => {
const settings = {
enabled: useSetting('RetentionPolicy_Enabled', false),
filesOnly: useSetting('RetentionPolicy_FilesOnly', false),
doNotPrunePinned: useSetting('RetentionPolicy_DoNotPrunePinned', false),
ignoreThreads: useSetting('RetentionPolicy_DoNotPruneThreads', true),
appliesToChannels: useSetting('RetentionPolicy_AppliesToChannels', false),
maxAgeChannels: useSetting('RetentionPolicy_TTL_Channels', 2592000000),
appliesToGroups: useSetting('RetentionPolicy_AppliesToGroups', false),
maxAgeGroups: useSetting('RetentionPolicy_TTL_Groups', 2592000000),
appliesToDMs: useSetting('RetentionPolicy_AppliesToDMs', false),
maxAgeDMs: useSetting('RetentionPolicy_TTL_DMs', 2592000000),
} as const;
if (!room) {
return undefined;
}
return {
enabled: settings.enabled,
isActive: isActive(room, settings),
filesOnly: extractFilesOnly(room, settings),
excludePinned: extractExcludePinned(room, settings),
ignoreThreads: extractIgnoreThreads(room, settings),
maxAge: getMaxAge(room, settings),
};
};

View File

@@ -0,0 +1,84 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRoomInvitation } from './useRoomInvitation';
import { createFakeRoom } from '../../../../tests/mocks/data';
import { createDeferredPromise } from '../../../../tests/mocks/utils/createDeferredMockFn';
const mockOpenConfirmationModal = jest.fn().mockResolvedValue(true);
jest.mock('./useRoomRejectInvitationModal', () => ({
useRoomRejectInvitationModal: () => ({
open: mockOpenConfirmationModal,
close: jest.fn(),
}),
}));
const mockInviteEndpoint = jest.fn();
const mockedNavigate = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useRouter: jest.fn(() => ({
navigate: mockedNavigate,
})),
}));
describe('useRoomInvitation', () => {
const mockedRoom = createFakeRoom();
const roomId = mockedRoom._id;
const appRoot = mockAppRoot().withEndpoint('POST', '/v1/rooms.invite', mockInviteEndpoint).build();
beforeEach(() => {
jest.clearAllMocks();
});
it('should call endpoint with accept action when acceptInvite is called', async () => {
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
act(() => void result.current.acceptInvite());
await waitFor(() => expect(mockInviteEndpoint).toHaveBeenCalledWith({ roomId, action: 'accept' }));
});
it('should call endpoint with reject action when rejectInvite is called', async () => {
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
act(() => void result.current.rejectInvite());
await waitFor(() => expect(mockInviteEndpoint).toHaveBeenCalledWith({ roomId, action: 'reject' }));
});
it('should return isPending as true when mutation is in progress', async () => {
const deferred = createDeferredPromise();
mockInviteEndpoint.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
act(() => void result.current.acceptInvite());
await waitFor(() => expect(result.current.isPending).toBe(true));
act(() => deferred.resolve());
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it('should open confirmation modal when rejecting an invite', async () => {
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
act(() => void result.current.rejectInvite());
await waitFor(() => expect(mockOpenConfirmationModal).toHaveBeenCalled());
});
it('should not call reject endpoint if invitation rejection is cancelled', async () => {
mockOpenConfirmationModal.mockResolvedValueOnce(false);
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
act(() => void result.current.rejectInvite());
await waitFor(() => expect(mockInviteEndpoint).not.toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,16 @@
import { useRoomRejectInvitationModal } from './useRoomRejectInvitationModal';
import { useEndpointMutation } from '../../../hooks/useEndpointMutation';
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
export const useRoomInvitation = (room: IRoomWithFederationOriginalName) => {
const { open: openConfirmationModal } = useRoomRejectInvitationModal(room);
const replyInvite = useEndpointMutation('POST', '/v1/rooms.invite');
return {
...replyInvite,
acceptInvite: async () => replyInvite.mutate({ roomId: room._id, action: 'accept' }),
rejectInvite: async () => {
if (await openConfirmationModal()) replyInvite.mutate({ roomId: room._id, action: 'reject' });
},
};
};

View File

@@ -0,0 +1,139 @@
import { faker } from '@faker-js/faker/locale/af_ZA';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useRoomRejectInvitationModal } from './useRoomRejectInvitationModal';
import { createFakeRoom, createFakeSubscription } from '../../../../tests/mocks/data';
const roomName = faker.lorem.word();
const inviterUsername = `testusername`;
const mockedRoom = createFakeRoom({ t: 'c', name: roomName });
const mockedSubscription = createFakeSubscription({
t: 'c',
rid: mockedRoom._id,
status: 'INVITED',
inviter: { _id: 'inviterId', username: inviterUsername, name: 'Inviter Name' },
name: mockedRoom.name,
fname: mockedRoom.fname,
});
const appRoot = () =>
mockAppRoot()
.withTranslations('en', 'core', {
Reject_invitation: 'Reject invitation',
Reject_dm_invitation_description: "You're rejecting the invitation to join {{username}} in a conversation. This cannot be undone.",
Reject_channel_invitation_description:
"You're rejecting the invitation from {{username}} to join {{roomName}}. This cannot be undone.",
Cancel: 'Cancel',
unknown: 'unknown',
})
.withSubscription(mockedSubscription);
describe('useRoomRejectInvitationModal', () => {
it('should return open and close functions', () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
expect(result.current).toMatchObject({
open: expect.any(Function),
close: expect.any(Function),
});
});
it('should open modal when open is called', async () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
act(() => void result.current.open());
const dialog = await screen.findByRole('dialog', { name: 'Reject invitation' });
expect(dialog).toBeInTheDocument();
});
it('should resolve open with true when rejected', async () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
let answer = false;
act(() => {
void result.current.open().then((res) => {
answer = res;
});
});
const dialog = await screen.findByRole('dialog', { name: 'Reject invitation' });
expect(dialog).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Reject invitation' }));
expect(answer).toBe(true);
});
it('should resolve open with false when cancelled', async () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
let answer = false;
act(() => {
void result.current.open().then((res) => {
answer = res;
});
});
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(answer).toBe(false);
});
it('should resolve open with false when modal is closed', async () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
let answer = false;
act(() => {
void result.current.open().then((res) => {
answer = res;
});
});
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(answer).toBe(false);
});
it('should close modal when close is called', () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), { wrapper: appRoot().build() });
act(() => void result.current.open());
expect(screen.getByRole('dialog', { name: 'Reject invitation' })).toBeInTheDocument();
act(() => result.current.close());
expect(screen.queryByRole('dialog', { name: 'Reject invitation' })).not.toBeInTheDocument();
});
it('should display the correct description for rejecting DMs', () => {
const { result } = renderHook(() => useRoomRejectInvitationModal({ ...mockedRoom, t: 'd' }), {
wrapper: appRoot()
.withSubscriptions([{ ...mockedSubscription, t: 'd' }])
.build(),
});
act(() => void result.current.open());
expect(
screen.getByText(`You're rejecting the invitation to join @${inviterUsername} in a conversation. This cannot be undone.`),
).toBeInTheDocument();
});
it('should display the correct description for rejecting channels', () => {
const { result } = renderHook(() => useRoomRejectInvitationModal(mockedRoom), {
wrapper: appRoot().build(),
});
act(() => void result.current.open());
expect(
screen.getByText(`You're rejecting the invitation from @${inviterUsername} to join ${roomName}. This cannot be undone.`),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useUserSubscription } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoomName } from '../../../hooks/useRoomName';
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
type RoomRejectInvitationModalResult = {
open: () => Promise<boolean>;
close: () => void;
};
export const useRoomRejectInvitationModal = (room: IRoomWithFederationOriginalName): RoomRejectInvitationModalResult => {
const { t } = useTranslation();
const setModal = useSetModal();
const roomName = useRoomName(room) || t('unknown');
const { inviter } = useUserSubscription(room._id) ?? {};
const username = inviter?.username?.startsWith('@') ? inviter.username : `@${inviter?.username || t('unknown')}`;
const description =
room.t === 'd'
? t('Reject_dm_invitation_description', { username })
: t('Reject_channel_invitation_description', { username, roomName });
const close = useCallback((): void => setModal(null), [setModal]);
const open = useCallback(
() =>
new Promise<boolean>((resolve) => {
setModal(
<GenericModal
icon={null}
variant='danger'
title={t('Reject_invitation')}
confirmText={t('Reject_invitation')}
onConfirm={() => {
resolve(true);
setModal(null);
}}
onCancel={() => {
resolve(false);
close();
}}
>
{description}
</GenericModal>,
);
}),
[close, description, setModal, t],
);
return { open, close };
};

View File

@@ -0,0 +1,40 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useToggleFavoriteMutation } from './useToggleFavoriteMutation';
import { subscriptionsQueryKeys } from '../../../lib/queryKeys';
it('should work', async () => {
const endpointHandler = jest.fn(() => null);
const { result } = renderHook(() => useToggleFavoriteMutation(), {
wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.favorite', endpointHandler).build(),
});
result.current.mutate({ roomId: 'general', favorite: true, roomName: 'general' });
await waitFor(() => expect(result.current.status).toBe('success'));
expect(endpointHandler).toHaveBeenCalledWith({
roomId: 'general',
favorite: true,
});
});
it('should invalidate any subscription queries', async () => {
const queryClient = new QueryClient();
jest.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useToggleFavoriteMutation(), {
wrapper: mockAppRoot()
.withEndpoint('POST', '/v1/rooms.favorite', async () => null)
.wrap((children) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>)
.build(),
});
result.current.mutate({ roomId: 'general', favorite: true, roomName: 'general' });
await waitFor(() => expect(result.current.status).toBe('success'));
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: subscriptionsQueryKeys.subscription('general') });
});

View File

@@ -0,0 +1,47 @@
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
import { useEndpoint, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toggleFavoriteRoom } from '../../../lib/mutationEffects/room';
import { subscriptionsQueryKeys } from '../../../lib/queryKeys';
export const useToggleFavoriteMutation = () => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const uid = useUserId();
const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite');
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ roomId, favorite }: { roomId: string; favorite: boolean; roomName: string }) => {
await toggleFavorite({ roomId, favorite });
},
onMutate: ({ roomId, favorite }) => {
queryClient.setQueryData<SubscriptionWithRoom | null>(subscriptionsQueryKeys.subscription(roomId), (subscription) =>
subscription
? {
...subscription,
f: favorite,
}
: undefined,
);
},
onSuccess: (_data, { roomId, favorite, roomName }) => {
toggleFavoriteRoom(roomId, favorite, uid);
dispatchToastMessage({
type: 'success',
message: favorite
? t('__roomName__was_added_to_favorites', { roomName })
: t('__roomName__was_removed_from_favorites', { roomName }),
});
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: (_data, _error, { roomId }) => {
queryClient.invalidateQueries({ queryKey: subscriptionsQueryKeys.subscription(roomId) });
},
});
};

View File

@@ -0,0 +1,96 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor, act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useUnbanUser } from './useUnbanUser';
import { createFakeRoom } from '../../../../tests/mocks/data';
jest.mock('../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomName: () => 'general',
},
}));
const fakeRoom = createFakeRoom({ t: 'c', name: 'general' });
const appRoot = () =>
mockAppRoot()
.withRoom(fakeRoom)
.withEndpoint('POST', '/v1/rooms.unbanUser', () => null)
.withTranslations('en', 'core', {
Are_you_sure: 'Are you sure?',
Yes_unban_user: 'Yes, unban user',
The_user_will_be_unbanned_from__roomName__: 'The user will be unbanned from {{roomName}}',
});
describe('useUnbanUser', () => {
it('should return a function', () => {
const { result } = renderHook(() => useUnbanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
expect(result.current).toEqual(expect.any(Function));
});
it('should open a confirmation modal when called', () => {
const { result } = renderHook(() => useUnbanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Yes, unban user' })).toBeInTheDocument();
});
it('should call the unban endpoint when confirmed', async () => {
const unbanEndpoint = jest.fn(() => null);
const { result } = renderHook(() => useUnbanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().withEndpoint('POST', '/v1/rooms.unbanUser', unbanEndpoint).build(),
});
act(() => result.current('testuser'));
await userEvent.click(screen.getByRole('button', { name: 'Yes, unban user' }));
await waitFor(() => expect(unbanEndpoint).toHaveBeenCalledWith({ roomId: fakeRoom._id, username: 'testuser' }));
});
it('should close the modal after mutation settles', async () => {
const { result } = renderHook(() => useUnbanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Yes, unban user' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Are you sure?' })).not.toBeInTheDocument());
});
it('should close the modal when cancel is clicked', async () => {
const { result } = renderHook(() => useUnbanUser({ roomId: fakeRoom._id }), {
wrapper: appRoot().build(),
});
act(() => result.current('testuser'));
expect(screen.getByRole('dialog', { name: 'Are you sure?' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Are you sure?' })).not.toBeInTheDocument());
});
it('should throw if room is not found', () => {
expect(() =>
renderHook(() => useUnbanUser({ roomId: 'nonexistent' }), {
wrapper: appRoot()
.withRoom(undefined as unknown as IRoom)
.build(),
}),
).toThrow('error-invalid-room');
});
});

View File

@@ -0,0 +1,61 @@
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import { useEndpoint, useSetModal, useToastMessageDispatch, useUserRoom } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { roomsQueryKeys } from '../../../lib/queryKeys';
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
type UseUnbanUserProps = {
roomId: string;
};
export const useUnbanUser = ({ roomId }: UseUnbanUserProps) => {
const { t } = useTranslation();
const room = useUserRoom(roomId);
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const unbanUserEndpoint = useEndpoint('POST', '/v1/rooms.unbanUser');
if (!room) {
throw new Error('error-invalid-room');
}
const roomName = escapeHTML(roomCoordinator.getRoomName(room.t, room));
const { mutate: unbanUser } = useMutation({
mutationFn: ({ roomId, username }: { roomId: string; username: string }) => unbanUserEndpoint({ roomId, username }),
onSuccess: (_, { username }) => {
dispatchToastMessage({ type: 'success', message: t('User__username__unbanned_from__roomName__', { username, roomName }) });
void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.bannedUsers(room._id) });
void queryClient.invalidateQueries({ queryKey: roomsQueryKeys.members(room._id, room.t) });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
setModal(null);
},
});
return useCallback(
(username: string) => {
setModal(
<GenericModal
variant='danger'
title={t('Are_you_sure')}
confirmText={t('Yes_unban_user')}
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
onConfirm={() => unbanUser({ roomId: room._id, username })}
>
{t('The_user_will_be_unbanned_from__roomName__', { roomName })}
</GenericModal>,
);
},
[setModal, roomName, t, room._id, unbanUser],
);
};

View File

@@ -0,0 +1,13 @@
import type { IRole, IRoom, IUser } from '@rocket.chat/core-typings';
import { useCallback } from 'react';
import type { RoomRoles } from '../../../hooks/useRoomRolesQuery';
import { useRoomRolesQuery } from '../../../hooks/useRoomRolesQuery';
export const useUserHasRoomRole = (uid: IUser['_id'], rid: IRoom['_id'], role: IRole['name']): boolean =>
useRoomRolesQuery(rid, {
select: useCallback(
(records: RoomRoles[]) => records.some((record) => record.u._id === uid && record.roles.includes(role)),
[role, uid],
),
}).data ?? false;

View File

@@ -0,0 +1,99 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
useTranslation,
useUser,
useUserRoom,
useUserSubscription,
useToastMessageDispatch,
useAtLeastOnePermission,
useEndpoint,
} from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import * as Federation from '../../../../../lib/federation/Federation';
import { useAddMatrixUsers } from '../../../contextualBar/RoomMembers/AddUsers/AddMatrixUsers/useAddMatrixUsers';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import type { UserInfoAction } from '../useUserInfoActions';
const inviteUserEndpoints = {
c: '/v1/channels.invite',
p: '/v1/groups.invite',
} as const;
export const useAddUserAction = (
user: Pick<IUser, '_id' | 'username'>,
rid: IRoom['_id'],
reload?: () => void,
): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const currentUser = useUser();
const subscription = useUserSubscription(rid);
const dispatchToastMessage = useToastMessageDispatch();
const { username, _id: uid } = user;
if (!room) {
throw Error('Room not provided');
}
const hasPermissionToAddUsers = useAtLeastOnePermission(
useMemo(() => [room?.t === 'p' ? 'add-user-to-any-p-room' : 'add-user-to-any-c-room', 'add-user-to-joined-room'], [room?.t]),
rid,
);
const roomIsFederated = isRoomFederated(room);
const isFederationBlocked = room && !isRoomNativeFederated(room);
const userCanAdd =
room && user && roomIsFederated
? !isFederationBlocked && Federation.isEditableByTheUser(currentUser || undefined, room, subscription)
: hasPermissionToAddUsers;
const { roomCanInvite } = getRoomDirectives({ room, showingUserId: uid, userSubscription: subscription });
const inviteUser = useEndpoint('POST', inviteUserEndpoints[room.t === 'p' ? 'p' : 'c']);
const handleAddUser = useStableCallback(async ({ users }: { users: string[] }) => {
const [username] = users;
await inviteUser({ roomId: rid, username });
reload?.();
});
const addClickHandler = useAddMatrixUsers();
const addUserOptionAction = useStableCallback(async () => {
try {
const users = [username as string];
if (isRoomFederated(room)) {
addClickHandler.mutate({
users,
handleSave: handleAddUser,
});
} else {
await handleAddUser({ users });
}
dispatchToastMessage({ type: 'success', message: t('User_added') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error as Error });
}
});
const addUserOption = useMemo(
() =>
roomCanInvite && userCanAdd && room.archived !== true
? {
content: t('add-to-room'),
icon: 'user-plus' as const,
onClick: addUserOptionAction,
type: 'management' as const,
}
: undefined,
[roomCanInvite, userCanAdd, room.archived, t, addUserOptionAction],
);
return addUserOption;
};

View File

@@ -0,0 +1,44 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { usePermission, useUserRoom, useUserSubscription } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import { useBanUser } from '../../useBanUser';
import type { UserInfoAction } from '../useUserInfoActions';
export const useBanUserAction = (user: Pick<IUser, '_id' | 'username'>, roomId: IRoom['_id']): UserInfoAction | undefined => {
const { t } = useTranslation();
const room = useUserRoom(roomId);
const subscription = useUserSubscription(roomId);
if (!room) {
throw new Error('error-invalid-room');
}
if (!user.username) {
throw new Error('error-invalid-username');
}
const { _id: uid, username } = user;
const hasPermissionToBan = usePermission('ban-user', roomId);
const { roomCanBan } = getRoomDirectives({ room, showingUserId: uid, userSubscription: subscription });
const handleBan = useBanUser({ roomId });
return useMemo(() => {
if (!hasPermissionToBan || !roomCanBan) {
return undefined;
}
return {
content: t('Ban_user_from_room'),
icon: 'ban' as const,
onClick: () => handleBan(username),
type: 'moderation' as const,
variant: 'danger' as const,
};
}, [handleBan, roomCanBan, hasPermissionToBan, t, username]);
};

View File

@@ -0,0 +1,58 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
useTranslation,
useEndpoint,
useToastMessageDispatch,
useUserId,
useUserSubscription,
useUserRoom,
} from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import type { UserInfoAction } from '../useUserInfoActions';
export const useBlockUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const currentSubscription = useUserSubscription(rid);
const ownUserId = useUserId();
const { _id: uid } = user;
const room = useUserRoom(rid);
if (!room) {
throw Error('Room not provided');
}
const { roomCanBlock } = getRoomDirectives({ room, showingUserId: uid, userSubscription: currentSubscription });
const isUserBlocked = Boolean(currentSubscription?.blocker);
const toggleBlockUser = useEndpoint('POST', '/v1/im.blockUser');
const toggleBlockUserAction = useStableCallback(async () => {
try {
await toggleBlockUser({ roomId: rid, block: !isUserBlocked });
dispatchToastMessage({
type: 'success',
message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'),
});
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const toggleBlockUserOption = useMemo(
() =>
roomCanBlock && uid !== ownUserId
? {
content: t(isUserBlocked ? 'Unblock' : 'Block'),
icon: 'ban' as const,
onClick: toggleBlockUserAction,
}
: undefined,
[isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid],
);
return toggleBlockUserOption;
};

View File

@@ -0,0 +1,72 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
useTranslation,
usePermission,
useUserRoom,
useUserSubscription,
useEndpoint,
useToastMessageDispatch,
} from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useMemo } from 'react';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import { useUserHasRoomRole } from '../../useUserHasRoomRole';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
const getEndpoint = (roomType: string, isLeader: boolean) => {
if (roomType === 'p') {
return isLeader ? '/v1/groups.removeLeader' : '/v1/groups.addLeader';
}
return isLeader ? '/v1/channels.removeLeader' : '/v1/channels.addLeader';
};
export const useChangeLeaderAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const { _id: uid, username } = user;
const userCanSetLeader = usePermission('set-leader', rid);
const userSubscription = useUserSubscription(rid);
const dispatchToastMessage = useToastMessageDispatch();
if (!room) {
throw Error('Room not provided');
}
const { roomCanSetLeader } = getRoomDirectives({ room, showingUserId: uid, userSubscription });
const isLeader = useUserHasRoomRole(uid, rid, 'leader');
const toggleLeaderEndpoint = useEndpoint('POST', getEndpoint(room.t, isLeader));
const toggleOwnerMutation = useMutation({
mutationFn: async ({ roomId, userId }: { roomId: string; userId: string }) => {
await toggleLeaderEndpoint({ roomId, userId });
return t(isLeader ? 'removed__username__as__role_' : 'set__username__as__role_', { username, role: 'leader' });
},
onSuccess: (message) => {
dispatchToastMessage({ type: 'success', message });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const changeLeaderAction = useStableCallback(async () => toggleOwnerMutation.mutateAsync({ roomId: rid, userId: uid }));
const changeLeaderOption = useMemo(
() =>
roomCanSetLeader && userCanSetLeader
? {
content: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'),
icon: 'shield-alt' as const,
onClick: changeLeaderAction,
type: 'privileges' as UserInfoActionType,
}
: undefined,
[isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction],
);
return changeLeaderOption;
};

View File

@@ -0,0 +1,163 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import {
useTranslation,
usePermission,
useUserRoom,
useUserSubscription,
useUser,
useSetModal,
useEndpoint,
useToastMessageDispatch,
} from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import { useUserHasRoomRole } from '../../useUserHasRoomRole';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
const getWarningModalForFederatedRooms = (
closeModalFn: () => void,
handleConfirmFn: () => void,
title: string,
confirmText: string,
bodyText: string,
) => (
<GenericModal
variant='warning'
onClose={closeModalFn}
onConfirm={handleConfirmFn}
onCancel={closeModalFn}
title={title}
confirmText={confirmText}
>
{bodyText}
</GenericModal>
);
const getEndpoint = (roomType: string, isModerator: boolean) => {
if (roomType === 'p') {
return isModerator ? '/v1/groups.removeModerator' : '/v1/groups.addModerator';
}
return isModerator ? '/v1/channels.removeModerator' : '/v1/channels.addModerator';
};
export const useChangeModeratorAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const { _id: uid } = user;
const userCanSetModerator = usePermission('set-moderator', rid);
const isModerator = useUserHasRoomRole(uid, rid, 'moderator');
const userSubscription = useUserSubscription(rid);
const { _id: loggedUserId = '' } = useUser() || {};
const loggedUserIsModerator = useUserHasRoomRole(loggedUserId, rid, 'moderator');
const loggedUserIsOwner = useUserHasRoomRole(loggedUserId, rid, 'owner');
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
if (!room) {
throw Error('Room not provided');
}
const { roomCanSetModerator } = getRoomDirectives({ room, showingUserId: uid, userSubscription });
const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room));
const toggleModeratorEndpoint = useEndpoint('POST', getEndpoint(room.t, isModerator));
const toggleModerator = useMutation({
mutationFn: async ({ roomId, userId }: { roomId: string; userId: string }) => {
await toggleModeratorEndpoint({ roomId, userId });
return t(isModerator ? 'User__username__removed_from__room_name__moderators' : 'User__username__is_now_a_moderator_of__room_name_', {
username: user.username,
room_name: roomName,
});
},
onSuccess: (message) => {
dispatchToastMessage({ type: 'success', message });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleChangeModerator = useCallback(
({ userId }: { userId: string }) => {
if (!isRoomFederated(room)) {
return toggleModerator.mutateAsync({ roomId: rid, userId: uid });
}
const closeModal = () => setModal(null);
const handleConfirm = async () => {
await toggleModerator.mutateAsync({ roomId: rid, userId: uid });
closeModal();
};
const changingOwnRole = userId === loggedUserId;
if (changingOwnRole && loggedUserIsModerator) {
return setModal(
getWarningModalForFederatedRooms(
closeModal,
handleConfirm,
t('Federation_Matrix_losing_privileges'),
t('Yes_continue'),
t('Federation_Matrix_losing_privileges_warning'),
),
);
}
if (changingOwnRole && loggedUserIsOwner) {
return setModal(
getWarningModalForFederatedRooms(
closeModal,
handleConfirm,
t('Federation_Matrix_losing_privileges'),
t('Yes_continue'),
t('Federation_Matrix_losing_privileges_warning'),
),
);
}
if (!changingOwnRole && loggedUserIsModerator) {
return setModal(
getWarningModalForFederatedRooms(
closeModal,
handleConfirm,
t('Warning'),
t('Yes_continue'),
t('Federation_Matrix_giving_same_permission_warning'),
),
);
}
toggleModerator.mutateAsync({ roomId: rid, userId: uid });
},
[setModal, loggedUserId, loggedUserIsModerator, loggedUserIsOwner, t, rid, uid, toggleModerator, room],
);
const changeModeratorAction = useStableCallback(() => handleChangeModerator({ userId: uid }));
const roomIsFederated = isRoomFederated(room);
const isFederationBlocked = room && !isRoomNativeFederated(room);
const changeModeratorOption = useMemo(
() =>
(roomIsFederated && !isFederationBlocked && roomCanSetModerator) || (!roomIsFederated && roomCanSetModerator && userCanSetModerator)
? {
content: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'),
icon: 'shield-blank' as const,
onClick: changeModeratorAction,
type: 'privileges' as UserInfoActionType,
}
: undefined,
[changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator, roomIsFederated, isFederationBlocked],
);
return changeModeratorOption;
};

View File

@@ -0,0 +1,149 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import {
useTranslation,
usePermission,
useUserRoom,
useUserSubscription,
useSetModal,
useUser,
useToastMessageDispatch,
useEndpoint,
} from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import { useUserHasRoomRole } from '../../useUserHasRoomRole';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
const getWarningModalForFederatedRooms = (
closeModalFn: () => void,
handleConfirmFn: () => void,
title: string,
confirmText: string,
bodyText: string,
) => (
<GenericModal
variant='warning'
onClose={closeModalFn}
onConfirm={handleConfirmFn}
onCancel={closeModalFn}
title={title}
confirmText={confirmText}
>
{bodyText}
</GenericModal>
);
const getEndpoint = (roomType: string, isOwner: boolean) => {
if (roomType === 'p') {
return isOwner ? '/v1/groups.removeOwner' : '/v1/groups.addOwner';
}
return isOwner ? '/v1/channels.removeOwner' : '/v1/channels.addOwner';
};
export const useChangeOwnerAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const { _id: uid, username } = user;
const userCanSetOwner = usePermission('set-owner', rid);
const isOwner = useUserHasRoomRole(uid, rid, 'owner');
const userSubscription = useUserSubscription(rid);
const setModal = useSetModal();
const { _id: loggedUserId = '' } = useUser() || {};
const loggedUserIsOwner = useUserHasRoomRole(loggedUserId, rid, 'owner');
const dispatchToastMessage = useToastMessageDispatch();
if (!room) {
throw Error('Room not provided');
}
const { roomCanSetOwner } = getRoomDirectives({ room, showingUserId: uid, userSubscription });
const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room));
const toggleOwnerEndpoint = useEndpoint('POST', getEndpoint(room.t, isOwner));
const toggleOwnerMutation = useMutation({
mutationFn: async ({ roomId, userId }: { roomId: string; userId: string }) => {
await toggleOwnerEndpoint({ roomId, userId });
return t(isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_an_owner_of__room_name_', {
username,
room_name: roomName,
});
},
onSuccess: (message) => {
dispatchToastMessage({ type: 'success', message });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleChangeOwner = useCallback(() => {
if (!isRoomFederated(room)) {
return toggleOwnerMutation.mutateAsync({ roomId: rid, userId: uid });
}
const changingOwnRole = uid === loggedUserId;
const closeModal = () => {
setModal(null);
};
const handleConfirm = () => {
toggleOwnerMutation.mutateAsync({ roomId: rid, userId: uid });
closeModal();
};
if (changingOwnRole && loggedUserIsOwner) {
return setModal(
getWarningModalForFederatedRooms(
closeModal,
handleConfirm,
t('Federation_Matrix_losing_privileges'),
t('Yes_continue'),
t('Federation_Matrix_losing_privileges_warning'),
),
);
}
if (!changingOwnRole && loggedUserIsOwner) {
return setModal(
getWarningModalForFederatedRooms(
closeModal,
handleConfirm,
t('Warning'),
t('Yes_continue'),
t('Federation_Matrix_giving_same_permission_warning'),
),
);
}
toggleOwnerMutation.mutateAsync({ roomId: rid, userId: uid });
}, [room, loggedUserId, loggedUserIsOwner, toggleOwnerMutation, rid, uid, t, setModal]);
const changeOwnerAction = useStableCallback(async () => handleChangeOwner());
const roomIsFederated = isRoomFederated(room);
const isFederationBlocked = room && !isRoomNativeFederated(room);
const changeOwnerOption = useMemo(
() =>
(roomIsFederated && !isFederationBlocked && roomCanSetOwner) || (!roomIsFederated && roomCanSetOwner && userCanSetOwner)
? {
content: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'),
icon: 'shield-check' as const,
onClick: changeOwnerAction,
type: 'privileges' as UserInfoActionType,
}
: undefined,
[changeOwnerAction, roomCanSetOwner, userCanSetOwner, isOwner, t, roomIsFederated, isFederationBlocked],
);
return changeOwnerOption;
};

View File

@@ -0,0 +1,27 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useGoToDirectMessage } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { UserInfoAction } from '../useUserInfoActions';
export const useDirectMessageAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const { t } = useTranslation();
const openDirectMessage = useGoToDirectMessage(user, rid);
const openDirectMessageOption = useMemo(() => {
if (!openDirectMessage) {
return undefined;
}
return {
content: t('Direct_Message'),
icon: 'balloon' as const,
onClick: openDirectMessage,
type: 'communication',
} as const;
}, [openDirectMessage, t]);
return openDirectMessageOption;
};

View File

@@ -0,0 +1,60 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
useTranslation,
useUserSubscription,
useUserRoom,
useUserId,
useToastMessageDispatch,
useEndpoint,
} from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
export const useIgnoreUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const { _id: uid } = user;
const ownUserId = useUserId();
const dispatchToastMessage = useToastMessageDispatch();
const currentSubscription = useUserSubscription(rid);
const ignoreUser = useEndpoint('GET', '/v1/chat.ignoreUser');
const isIgnored = currentSubscription?.ignored && currentSubscription.ignored.indexOf(uid) > -1;
if (!room) {
throw Error('Room not provided');
}
const { roomCanIgnore } = getRoomDirectives({ room, showingUserId: uid, userSubscription: currentSubscription });
const ignoreUserAction = useStableCallback(async () => {
try {
await ignoreUser({ rid, userId: uid, ignore: String(!isIgnored) });
if (isIgnored) {
dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') });
} else {
dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') });
}
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const ignoreUserOption = useMemo(
() =>
roomCanIgnore && uid !== ownUserId
? {
content: t(isIgnored ? 'Unignore' : 'Ignore'),
icon: 'ban' as const,
onClick: ignoreUserAction,
type: 'management' as UserInfoActionType,
}
: undefined,
[ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid],
);
return ignoreUserOption;
};

View File

@@ -0,0 +1,126 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import {
useAllPermissions,
usePermission,
useSetModal,
useToastMessageDispatch,
useTranslation,
useUserRoom,
useUserSubscription,
useEndpoint,
} from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
const getUserIsMuted = (
user: Pick<IUser, '_id' | 'username'>,
room: IRoom | undefined,
userCanPostReadonly: boolean,
): boolean | undefined => {
if (room?.ro) {
if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user.username ?? '') !== -1) {
return false;
}
if (userCanPostReadonly) {
return Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') !== -1;
}
return true;
}
return room && Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') > -1;
};
export const useMuteUserAction = (user: Pick<IUser, '_id' | 'username'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const t = useTranslation();
const room = useUserRoom(rid);
const userCanMute = usePermission('mute-user', rid);
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal(null));
const otherUserCanPostReadonly = useAllPermissions(
useMemo(() => ['post-readonly'], []),
rid,
);
const userSubscription = useUserSubscription(rid);
const isMuted = getUserIsMuted(user, room, otherUserCanPostReadonly);
const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room));
if (!room) {
throw Error('Room not provided');
}
const { roomCanMute } = getRoomDirectives({ room, showingUserId: user._id, userSubscription });
const mutedMessage = isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__';
const muteUser = useEndpoint('POST', isMuted ? '/v1/rooms.unmuteUser' : '/v1/rooms.muteUser');
const muteUserOption = useMemo(() => {
const action = (): Promise<void> | void => {
const onConfirm = async (): Promise<void> => {
try {
if (!user.username) {
throw new Error('User without username');
}
await muteUser({ roomId: rid, username: user.username });
return dispatchToastMessage({
type: 'success',
message: t(mutedMessage, {
username: user.username,
roomName,
}),
});
} catch (error: unknown) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
closeModal();
}
};
if (isMuted) {
return onConfirm();
}
return setModal(
<GenericModal variant='danger' confirmText={t('Yes_mute_user')} onClose={closeModal} onCancel={closeModal} onConfirm={onConfirm}>
{t('The_user_wont_be_able_to_type_in_s', { roomName })}
</GenericModal>,
);
};
return roomCanMute && userCanMute
? {
content: t(isMuted ? 'Unmute_user' : 'Mute_user'),
icon: isMuted ? ('mic' as const) : ('mic-off' as const),
onClick: action,
type: 'management' as UserInfoActionType,
}
: undefined;
}, [
closeModal,
mutedMessage,
dispatchToastMessage,
isMuted,
muteUser,
rid,
roomCanMute,
roomName,
setModal,
t,
user.username,
userCanMute,
]);
return muteUserOption;
};

View File

@@ -0,0 +1,27 @@
import type { IUser } from '@rocket.chat/core-typings';
import { usePermission, useRoute } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import type { UserInfoAction, UserInfoActionType } from '../useUserInfoActions';
export const useRedirectModerationConsole = (uid: IUser['_id']): UserInfoAction | undefined => {
const { t } = useTranslation();
const hasPermissionToView = usePermission('view-moderation-console');
const router = useRoute('moderation-console');
// only rediret if user has permission else return undefined
if (!hasPermissionToView) {
return;
}
const redirectModerationConsoleAction = () => {
router.push({ uid });
};
return {
content: t('Moderation_Action_View_reports'),
icon: 'warning',
onClick: redirectModerationConsoleAction,
type: 'privileges' as UserInfoActionType,
};
};

View File

@@ -0,0 +1,145 @@
import type { IRoom, IUser, Serialized } from '@rocket.chat/core-typings';
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { escapeHTML } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import {
usePermission,
useSetModal,
useToastMessageDispatch,
useTranslation,
useUser,
useUserRoom,
useUserSubscription,
} from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useEndpointMutation } from '../../../../../hooks/useEndpointMutation';
import * as Federation from '../../../../../lib/federation/Federation';
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import RemoveUsersModal from '../../../../teams/contextualBar/members/RemoveUsersModal';
import { getRoomDirectives } from '../../../lib/getRoomDirectives';
import type { UserInfoAction } from '../useUserInfoActions';
export const useRemoveUserAction = (
user: Pick<IUser, '_id' | 'username'>,
rid: IRoom['_id'],
reload?: () => void,
invited?: boolean,
): UserInfoAction | undefined => {
const room = useUserRoom(rid);
if (!room) {
throw Error('Room not provided');
}
const t = useTranslation();
const queryClient = useQueryClient();
const currentUser = useUser();
const subscription = useUserSubscription(rid);
const { _id: uid } = user;
const hasPermissionToRemove = usePermission('remove-user', rid);
const roomIsFederated = isRoomFederated(room);
const isFederationBlocked = room && !isRoomNativeFederated(room);
const userCanRemove = roomIsFederated
? !isFederationBlocked && Federation.isEditableByTheUser(currentUser || undefined, room, subscription)
: hasPermissionToRemove;
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal(null));
const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room));
const { roomCanRemove } = getRoomDirectives({ room, showingUserId: uid, userSubscription: subscription });
const dispatchToastMessage = useToastMessageDispatch();
const { mutateAsync: removeFromTeam } = useEndpointMutation('POST', '/v1/teams.removeMember', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('User_has_been_removed_from_team') });
},
onSettled: () => {
closeModal();
reload?.();
},
});
const removeFromRoomEndpoint = room.t === 'p' ? '/v1/groups.kick' : '/v1/channels.kick';
const { mutateAsync: removeFromRoom } = useEndpointMutation('POST', removeFromRoomEndpoint, {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('User_has_been_removed_from_s', { roomName }) });
queryClient.invalidateQueries({ queryKey: roomsQueryKeys.members(room._id, room.t) });
},
onSettled: () => {
closeModal();
reload?.();
},
});
const removeUserOptionAction = useStableCallback(() => {
const handleRemoveFromTeam = async (rooms: Record<string, Serialized<IRoom>>) => {
if (room.teamId) {
const roomKeys = Object.keys(rooms);
await removeFromTeam({
teamId: room.teamId,
userId: uid,
...(roomKeys.length && { rooms: roomKeys }),
});
}
};
const handleRemoveFromRoom = async (rid: IRoom['_id'], uid: IUser['_id']) => {
await removeFromRoom({ roomId: rid, userId: uid });
};
if (room.teamMain && room.teamId) {
return setModal(
<RemoveUsersModal teamId={room?.teamId} userId={uid} onClose={closeModal} onCancel={closeModal} onConfirm={handleRemoveFromTeam} />,
);
}
setModal(
<GenericModal
variant='danger'
confirmText={invited ? t('Revoke_invitation') : t('Yes_remove_user')}
onClose={closeModal}
onCancel={closeModal}
onConfirm={(): Promise<void> => handleRemoveFromRoom(rid, uid)}
>
{t('The_user_will_be_removed_from_s', { roomName })}
</GenericModal>,
);
});
const content = useMemo(() => {
if (invited) {
return t('Revoke_invitation');
}
if (room?.teamMain) {
return t('Remove_from_team');
}
return t('Remove_from_room');
}, [invited, room?.teamMain, t]);
const removeUserOption = useMemo(() => {
if (!roomCanRemove || !userCanRemove) {
return undefined;
}
return {
content,
icon: 'cross' as const,
onClick: removeUserOptionAction,
type: 'moderation' as const,
variant: 'danger' as const,
};
}, [roomCanRemove, userCanRemove, removeUserOptionAction, content]);
return removeUserOption;
};

View File

@@ -0,0 +1,56 @@
import type { IUser } from '@rocket.chat/core-typings';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { useEndpoint, useSetModal, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ReportUserModal from '../../../contextualBar/UserInfo/ReportUserModal';
import type { UserInfoAction } from '../useUserInfoActions';
export const useReportUser = (user: Pick<IUser, '_id' | 'username' | 'name'>): UserInfoAction | undefined => {
const { _id: uid, username, name } = user;
const ownUserId = useUserId();
const setModal = useSetModal();
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const displayName = useUserDisplayName({ username, name });
const reportUser = useEndpoint('POST', '/v1/moderation.reportUser');
const reportUserMutation = useMutation({
mutationKey: ['reportUser', uid],
mutationFn: async (description: string) => {
reportUser({ description, userId: uid });
},
onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Report_has_been_sent') }),
onError: (error) => dispatchToastMessage({ type: 'error', message: error }),
onSettled: () => setModal(),
});
const openReportUserModal = useMemo(() => {
const action = () =>
setModal(
<ReportUserModal
onConfirm={reportUserMutation.mutate}
onClose={() => setModal()}
displayName={displayName || ''}
username={username || ''}
/>,
);
return ownUserId !== uid
? {
icon: 'warning' as const,
content: t('Report'),
onClick: action,
type: 'moderation' as const,
variant: 'danger' as const,
}
: undefined;
}, [ownUserId, uid, t, setModal, username, reportUserMutation.mutate, displayName]);
return openReportUserModal;
};

View File

@@ -0,0 +1,128 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, renderHook } from '@testing-library/react';
import { useUserMediaCallAction } from './useUserMediaCallAction';
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../../../tests/mocks/data';
const usePeekMediaSessionStateMock = jest.fn().mockReturnValue('available');
const toggleWidgetMock = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useUserAvatarPath: jest.fn().mockReturnValue((_args: any) => 'avatar-url'),
useUserCard: jest.fn().mockReturnValue({ closeUserCard: jest.fn() }),
}));
jest.mock('@rocket.chat/ui-voip', () => ({
...jest.requireActual('@rocket.chat/ui-voip'),
useWidgetExternalControls: jest.fn().mockReturnValue({ toggleWidget: (...args: any[]) => toggleWidgetMock(...args) }),
usePeekMediaSessionState: () => usePeekMediaSessionStateMock(),
}));
describe('useUserMediaCallAction', () => {
const fakeUser = createFakeUser({ _id: 'own-uid' });
const mockRid = 'room-id';
afterEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
usePeekMediaSessionStateMock.mockReturnValue('available');
});
it('should return undefined if room is federated', () => {
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot()
.withJohnDoe()
.withRoom(createFakeRoom({ federated: true }))
.build(),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if state is unauthorized', () => {
usePeekMediaSessionStateMock.mockReturnValueOnce('unavailable');
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot().build() });
expect(result.current).toBeUndefined();
});
it('should return undefined if subscription is blocked', () => {
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot()
.withJohnDoe()
.withRoom(createFakeRoom())
.withSubscription(createFakeSubscription({ blocker: false, blocked: true }))
.build(),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if subscription is blocker', () => {
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot()
.withJohnDoe()
.withRoom(createFakeRoom())
.withSubscription(createFakeSubscription({ blocker: true, blocked: false }))
.build(),
});
expect(result.current).toBeUndefined();
});
it('should return undefined if user is own user', () => {
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot().withUser(fakeUser).withRoom(createFakeRoom()).withSubscription(createFakeSubscription()).build(),
});
expect(result.current).toBeUndefined();
});
it('should return action if conditions are met', () => {
const fakeUser = createFakeUser();
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), {
wrapper: mockAppRoot()
.withJohnDoe()
.withRoom(createFakeRoom())
.withSubscription(createFakeSubscription())
.withTranslations('en', 'core', {
Voice_call__user_: 'Voice call {{user}}',
})
.build(),
});
expect(result.current).toEqual(
expect.objectContaining({
type: 'communication',
icon: 'phone',
title: `Voice call ${fakeUser.name}`,
disabled: false,
}),
);
});
it('should call onClick handler correctly', () => {
usePeekMediaSessionStateMock.mockReturnValueOnce('available');
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid));
act(() => result.current?.onClick());
expect(toggleWidgetMock).toHaveBeenCalledWith({
userId: fakeUser._id,
displayName: fakeUser.name,
avatarUrl: 'avatar-url',
});
});
it('should be disabled if state is not closed, new, or unlicensed', () => {
usePeekMediaSessionStateMock.mockReturnValueOnce('calling');
const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid));
expect(result.current?.disabled).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { useUserAvatarPath, useUserId, useUserSubscription, useUserCard, useUserRoom } from '@rocket.chat/ui-contexts';
import { usePeekMediaSessionState, useWidgetExternalControls } from '@rocket.chat/ui-voip';
import { useTranslation } from 'react-i18next';
import type { UserInfoAction } from '../useUserInfoActions';
export const useUserMediaCallAction = (user: Pick<IUser, '_id' | 'username' | 'name'>, rid: IRoom['_id']): UserInfoAction | undefined => {
const { t } = useTranslation();
const ownUserId = useUserId();
const { closeUserCard } = useUserCard();
const state = usePeekMediaSessionState();
const { toggleWidget } = useWidgetExternalControls();
const getAvatarUrl = useUserAvatarPath();
const currentSubscription = useUserSubscription(rid);
const room = useUserRoom(rid);
const blocked = currentSubscription?.blocked || currentSubscription?.blocker;
if (room && isRoomFederated(room)) {
return undefined;
}
if (state === 'unavailable') {
return undefined;
}
if (blocked) {
return undefined;
}
const disabled = state !== 'available';
if (user._id === ownUserId) {
return undefined;
}
const avatarUrl = user.username ? getAvatarUrl({ username: user.username }) : getAvatarUrl({ userId: user._id });
return {
type: 'communication',
title: t('Voice_call__user_', { user: user.name || user.username || '' }),
icon: 'phone',
onClick: () => {
closeUserCard();
toggleWidget({
userId: user._id,
displayName: user.name || user.username || '',
avatarUrl,
});
},
disabled,
};
};

View File

@@ -0,0 +1,81 @@
import type { IUser } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import {
useTranslation,
useUserRoom,
useUserId,
useUserSubscriptionByName,
useSetting,
usePermission,
useUserCard,
} from '@rocket.chat/ui-contexts';
import {
useVideoConfDispatchOutgoing,
useVideoConfIsCalling,
useVideoConfIsRinging,
useVideoConfLoadCapabilities,
} from '@rocket.chat/ui-video-conf';
import { useMemo } from 'react';
import { useVideoConfWarning } from '../../../contextualBar/VideoConference/hooks/useVideoConfWarning';
import type { UserInfoAction } from '../useUserInfoActions';
export const useVideoCallAction = (user: Pick<IUser, '_id' | 'username'>): UserInfoAction | undefined => {
const t = useTranslation();
const usernameSubscription = useUserSubscriptionByName(user.username ?? '');
const room = useUserRoom(usernameSubscription?.rid || '');
const { closeUserCard } = useUserCard();
const loadCapabilities = useVideoConfLoadCapabilities();
const dispatchWarning = useVideoConfWarning();
const dispatchPopup = useVideoConfDispatchOutgoing();
const isCalling = useVideoConfIsCalling();
const isRinging = useVideoConfIsRinging();
const ownUserId = useUserId();
const enabledForDMs = useSetting('VideoConf_Enable_DMs');
const permittedToCallManagement = usePermission('call-management', room?._id);
const videoCallOption = useMemo<UserInfoAction | undefined>(() => {
const action = async (): Promise<void> => {
if (isCalling || isRinging || !room) {
return;
}
try {
await loadCapabilities();
closeUserCard();
dispatchPopup({ rid: room._id });
} catch (error: any) {
dispatchWarning(error.error);
}
};
const shouldShowStartCall =
room && !isRoomFederated(room) && user._id !== ownUserId && enabledForDMs && permittedToCallManagement && !isCalling && !isRinging;
return shouldShowStartCall
? {
type: 'communication',
title: t('Video_call'),
icon: 'video',
onClick: action,
}
: undefined;
}, [
room,
user._id,
ownUserId,
enabledForDMs,
permittedToCallManagement,
isCalling,
isRinging,
t,
dispatchPopup,
dispatchWarning,
closeUserCard,
loadCapabilities,
]);
return videoCallOption;
};

View File

@@ -0,0 +1,2 @@
export { useUserInfoActions } from './useUserInfoActions';
export type { UserInfoAction, UserMenuAction } from './useUserInfoActions';

View File

@@ -0,0 +1,162 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
import { useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useAddUserAction } from './actions/useAddUserAction';
import { useBanUserAction } from './actions/useBanUserAction';
import { useBlockUserAction } from './actions/useBlockUserAction';
import { useChangeLeaderAction } from './actions/useChangeLeaderAction';
import { useChangeModeratorAction } from './actions/useChangeModeratorAction';
import { useChangeOwnerAction } from './actions/useChangeOwnerAction';
import { useDirectMessageAction } from './actions/useDirectMessageAction';
import { useIgnoreUserAction } from './actions/useIgnoreUserAction';
import { useMuteUserAction } from './actions/useMuteUserAction';
import { useRedirectModerationConsole } from './actions/useRedirectModerationConsole';
import { useRemoveUserAction } from './actions/useRemoveUserAction';
import { useReportUser } from './actions/useReportUser';
import { useUserMediaCallAction } from './actions/useUserMediaCallAction';
import { useVideoCallAction } from './actions/useVideoCallAction';
export type UserInfoActionType = 'communication' | 'privileges' | 'management' | 'moderation';
type UserInfoActionWithOnlyIcon = {
type?: UserInfoActionType;
content?: string;
icon: ComponentProps<typeof Icon>['name'];
title: string;
variant?: 'danger';
onClick: () => void;
disabled?: boolean;
};
type UserInfoActionWithContent = {
type?: UserInfoActionType;
content: string;
icon?: ComponentProps<typeof Icon>['name'];
title?: string;
variant?: 'danger';
onClick: () => void;
disabled?: boolean;
};
export type UserInfoAction = UserInfoActionWithContent | UserInfoActionWithOnlyIcon;
export type UserMenuAction = {
id: string;
title: string;
items: GenericMenuItemProps[];
}[];
type UserInfoActionsParams = {
user: Pick<IUser, '_id' | 'username' | 'name' | 'freeSwitchExtension'>;
rid: IRoom['_id'];
reload?: () => void;
size?: number;
isMember?: boolean;
isInvited?: boolean;
};
type UseUserInfoActionsResult = {
actions: [string, UserInfoAction][];
menuActions: UserMenuAction | undefined;
};
export const useUserInfoActions = ({
user,
rid,
reload,
size = 2,
isMember,
isInvited,
}: UserInfoActionsParams): UseUserInfoActionsResult => {
const addUser = useAddUserAction(user, rid, reload);
const blockUser = useBlockUserAction(user, rid);
const changeLeader = useChangeLeaderAction(user, rid);
const changeModerator = useChangeModeratorAction(user, rid);
const openModerationConsole = useRedirectModerationConsole(user._id);
const changeOwner = useChangeOwnerAction(user, rid);
const openDirectMessage = useDirectMessageAction(user, rid);
const ignoreUser = useIgnoreUserAction(user, rid);
const muteUser = useMuteUserAction(user, rid);
const removeUser = useRemoveUserAction(user, rid, reload, isInvited);
const banUser = useBanUserAction(user, rid);
const videoCall = useVideoCallAction(user);
const reportUserOption = useReportUser(user);
const isLayoutEmbedded = useEmbeddedLayout();
const { userToolbox: hiddenActions } = useLayoutHiddenActions();
const userMediaCall = useUserMediaCallAction(user, rid);
const userinfoActions = useMemo(
() => ({
...(openDirectMessage && !isLayoutEmbedded && { openDirectMessage }),
...(videoCall && { videoCall }),
...(userMediaCall && { userMediaCall }),
...(!isMember && addUser && { addUser }),
...(isMember && changeOwner && { changeOwner }),
...(isMember && changeLeader && { changeLeader }),
...(isMember && changeModerator && { changeModerator }),
...(isMember && openModerationConsole && { openModerationConsole }),
...(isMember && ignoreUser && { ignoreUser }),
...(isMember && muteUser && { muteUser }),
...(blockUser && { toggleBlock: blockUser }),
...((isMember || isInvited) && removeUser && { removeUser }),
...((isMember || isInvited) && banUser && { banUser }),
...(reportUserOption && { reportUser: reportUserOption }),
}),
[
openDirectMessage,
isLayoutEmbedded,
videoCall,
userMediaCall,
changeOwner,
changeLeader,
changeModerator,
ignoreUser,
muteUser,
blockUser,
removeUser,
reportUserOption,
openModerationConsole,
addUser,
banUser,
isMember,
isInvited,
],
);
const actionSpread = useMemo(() => {
const entries = Object.entries(userinfoActions).filter(([key]) => !hiddenActions.includes(key));
const options = entries.slice(0, size);
const slicedOptions = entries.slice(size, entries.length);
const menuActions = slicedOptions.reduce((acc, [_key, item]) => {
const group = item.type ? item.type : '';
const section = acc.find((section: { id: string }) => section.id === group);
const newItem = {
...item,
id: item.content || item.title || '',
content: item.content || item.title,
};
if (section) {
section.items.push(newItem);
return acc;
}
const newSection = { id: group, title: '', items: [newItem] };
acc.push(newSection);
return acc;
}, [] as UserMenuAction);
return { actions: options, menuActions };
}, [size, userinfoActions, hiddenActions]);
return actionSpread;
};

View File

@@ -0,0 +1,24 @@
import type { ITeam, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { teamsQueryKeys } from '../../../lib/queryKeys';
type UserTeamsQueryOptions<TData = ITeam[]> = Omit<
UseQueryOptions<Serialized<ITeam[]>, Error, TData, ReturnType<typeof teamsQueryKeys.listUserTeams>>,
'queryKey' | 'queryFn'
>;
export const useUserTeamsQuery = <TData = ITeam[]>(userId: string, options: UserTeamsQueryOptions<TData> = {}) => {
const userTeamsListEndpoint = useEndpoint('GET', '/v1/users.listTeams');
return useQuery({
queryKey: teamsQueryKeys.listUserTeams(userId),
queryFn: async () => {
const response = await userTeamsListEndpoint({ userId });
return response.teams;
},
...options,
});
};