snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -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();
|
||||
});
|
||||
43
apps/meteor/client/views/room/hooks/useAppsContextualBar.ts
Normal file
43
apps/meteor/client/views/room/hooks/useAppsContextualBar.ts
Normal 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;
|
||||
};
|
||||
96
apps/meteor/client/views/room/hooks/useBanUser.spec.ts
Normal file
96
apps/meteor/client/views/room/hooks/useBanUser.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
63
apps/meteor/client/views/room/hooks/useBanUser.tsx
Normal file
63
apps/meteor/client/views/room/hooks/useBanUser.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
188
apps/meteor/client/views/room/hooks/useDateScroll.ts
Normal file
188
apps/meteor/client/views/room/hooks/useDateScroll.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
110
apps/meteor/client/views/room/hooks/useE2EEResetRoomKey.spec.ts
Normal file
110
apps/meteor/client/views/room/hooks/useE2EEResetRoomKey.spec.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
39
apps/meteor/client/views/room/hooks/useE2EEResetRoomKey.ts
Normal file
39
apps/meteor/client/views/room/hooks/useE2EEResetRoomKey.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
12
apps/meteor/client/views/room/hooks/useE2EERoom.ts
Normal file
12
apps/meteor/client/views/room/hooks/useE2EERoom.ts
Normal 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;
|
||||
};
|
||||
19
apps/meteor/client/views/room/hooks/useE2EERoomState.ts
Normal file
19
apps/meteor/client/views/room/hooks/useE2EERoomState.ts
Normal 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);
|
||||
};
|
||||
9
apps/meteor/client/views/room/hooks/useE2EEState.ts
Normal file
9
apps/meteor/client/views/room/hooks/useE2EEState.ts
Normal 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);
|
||||
42
apps/meteor/client/views/room/hooks/useFileAsDataURL.ts
Normal file
42
apps/meteor/client/views/room/hooks/useFileAsDataURL.ts
Normal 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];
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
39
apps/meteor/client/views/room/hooks/useGoToRoom.ts
Normal file
39
apps/meteor/client/views/room/hooks/useGoToRoom.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
25
apps/meteor/client/views/room/hooks/useGoToThread.ts
Normal file
25
apps/meteor/client/views/room/hooks/useGoToThread.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
};
|
||||
28
apps/meteor/client/views/room/hooks/useGoToThreadList.ts
Normal file
28
apps/meteor/client/views/room/hooks/useGoToThreadList.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
};
|
||||
27
apps/meteor/client/views/room/hooks/useIsVisible.ts
Normal file
27
apps/meteor/client/views/room/hooks/useIsVisible.ts
Normal 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;
|
||||
};
|
||||
116
apps/meteor/client/views/room/hooks/useMessageListNavigation.ts
Normal file
116
apps/meteor/client/views/room/hooks/useMessageListNavigation.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
136
apps/meteor/client/views/room/hooks/useOpenRoom.spec.ts
Normal file
136
apps/meteor/client/views/room/hooks/useOpenRoom.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
apps/meteor/client/views/room/hooks/useOpenRoom.ts
Normal file
178
apps/meteor/client/views/room/hooks/useOpenRoom.ts
Normal 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;
|
||||
}
|
||||
32
apps/meteor/client/views/room/hooks/useOpenRoomMutation.ts
Normal file
32
apps/meteor/client/views/room/hooks/useOpenRoomMutation.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
122
apps/meteor/client/views/room/hooks/useRetentionPolicy.ts
Normal file
122
apps/meteor/client/views/room/hooks/useRetentionPolicy.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
16
apps/meteor/client/views/room/hooks/useRoomInvitation.ts
Normal file
16
apps/meteor/client/views/room/hooks/useRoomInvitation.ts
Normal 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' });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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') });
|
||||
});
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
96
apps/meteor/client/views/room/hooks/useUnbanUser.spec.ts
Normal file
96
apps/meteor/client/views/room/hooks/useUnbanUser.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
61
apps/meteor/client/views/room/hooks/useUnbanUser.tsx
Normal file
61
apps/meteor/client/views/room/hooks/useUnbanUser.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
13
apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts
Normal file
13
apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useUserInfoActions } from './useUserInfoActions';
|
||||
export type { UserInfoAction, UserMenuAction } from './useUserInfoActions';
|
||||
@@ -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;
|
||||
};
|
||||
24
apps/meteor/client/views/room/hooks/useUserTeamsQuery.ts
Normal file
24
apps/meteor/client/views/room/hooks/useUserTeamsQuery.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user