mirror of
https://github.com/RocketChat/Rocket.Chat.git
synced 2025-12-28 06:47:25 +00:00
feat: Added pending invitation state to rooms (#37612)
Co-authored-by: Diego Sampaio <chinello@gmail.com> Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz>
This commit is contained in:
parent
a22df0948a
commit
176d5eae3f
7
.changeset/chatty-roses-help.md
Normal file
7
.changeset/chatty-roses-help.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
"@rocket.chat/meteor": patch
|
||||
"@rocket.chat/core-typings": patch
|
||||
"@rocket.chat/i18n": patch
|
||||
---
|
||||
|
||||
Adds invitation request support to rooms
|
||||
@ -168,7 +168,7 @@ export async function createDirectRoom(
|
||||
status: 'INVITED',
|
||||
inviter: {
|
||||
_id: creatorUser._id,
|
||||
username: creatorUser.username,
|
||||
username: creatorUser.username!,
|
||||
name: creatorUser.name,
|
||||
},
|
||||
open: true,
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
import type { ILivechatDepartment, IMessage, IRoom, ITeam, IUser, ILivechatAgent, IOutboundProvider } from '@rocket.chat/core-typings';
|
||||
import type {
|
||||
ILivechatDepartment,
|
||||
IMessage,
|
||||
IRoom,
|
||||
ITeam,
|
||||
IUser,
|
||||
ILivechatAgent,
|
||||
IOutboundProvider,
|
||||
RoomType,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
|
||||
export const roomsQueryKeys = {
|
||||
all: ['rooms'] as const,
|
||||
room: (rid: IRoom['_id']) => ['rooms', rid] as const,
|
||||
roomReference: (reference: string, type: RoomType, uid?: IUser['_id'], username?: IUser['username']) => [
|
||||
...roomsQueryKeys.all,
|
||||
reference,
|
||||
type,
|
||||
uid ?? username,
|
||||
],
|
||||
starredMessages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'starred-messages'] as const,
|
||||
pinnedMessages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'pinned-messages'] as const,
|
||||
messages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'messages'] as const,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom, isVoipRoom } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom, isVoipRoom, isInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
import { lazy, memo, useMemo } from 'react';
|
||||
@ -7,6 +7,7 @@ import { lazy, memo, useMemo } from 'react';
|
||||
import { HeaderToolbar } from '../../../components/Header';
|
||||
import SidebarToggler from '../../../components/SidebarToggler';
|
||||
|
||||
const RoomInviteHeader = lazy(() => import('./RoomInviteHeader'));
|
||||
const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader'));
|
||||
const VoipRoomHeader = lazy(() => import('./Omnichannel/VoipRoomHeader'));
|
||||
const RoomHeaderE2EESetup = lazy(() => import('./RoomHeaderE2EESetup'));
|
||||
@ -15,9 +16,10 @@ const RoomHeader = lazy(() => import('./RoomHeader'));
|
||||
|
||||
type HeaderProps<T> = {
|
||||
room: T;
|
||||
subscription?: ISubscription;
|
||||
};
|
||||
|
||||
const Header = ({ room }: HeaderProps<IRoom>): ReactElement | null => {
|
||||
const Header = ({ room, subscription }: HeaderProps<IRoom>): ReactElement | null => {
|
||||
const { isMobile, isEmbedded, showTopNavbarEmbeddedLayout } = useLayout();
|
||||
const encrypted = Boolean(room.encrypted);
|
||||
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
|
||||
@ -38,6 +40,10 @@ const Header = ({ room }: HeaderProps<IRoom>): ReactElement | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription && isInviteSubscription(subscription)) {
|
||||
return <RoomInviteHeader room={room} />;
|
||||
}
|
||||
|
||||
if (room.t === 'l') {
|
||||
return <OmnichannelRoomHeader slots={slots} />;
|
||||
}
|
||||
|
||||
80
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
80
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ prid: undefined });
|
||||
const appRoot = mockAppRoot()
|
||||
.withRoom(mockedRoom)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={mockedRoom}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
jest.mock('../../../../app/utils/client', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoomWithData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoomWithData</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./ParentTeam', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentTeam</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomHeader', () => {
|
||||
describe('Toolbox', () => {
|
||||
it('should render toolbox by default', async () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{}} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render toolbox if roomToolbox is null and no slots are provided', () => {
|
||||
render(
|
||||
<RoomHeader
|
||||
room={mockedRoom}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render toolbox if slots.toolbox is provided', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: {} }} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from roomToolbox prop', () => {
|
||||
render(
|
||||
<RoomHeader
|
||||
room={mockedRoom}
|
||||
slots={{
|
||||
toolbox: {
|
||||
content: <div>Custom Toolbox</div>,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
expect(screen.getByText('Custom Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from slots.toolbox.content', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Slotted Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RoomInviteHeader.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
jest.mock('../../../../app/utils/client', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoomWithData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoomWithData</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./ParentTeam', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentTeam</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomInviteHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInviteHeader from './RoomInviteHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ name: 'rocket.cat', federated: true });
|
||||
|
||||
const meta = {
|
||||
component: RoomInviteHeader,
|
||||
args: {
|
||||
room: mockedRoom,
|
||||
},
|
||||
decorators: [(story) => <FakeRoomProvider roomOverrides={mockedRoom}>{story()}</FakeRoomProvider>],
|
||||
} satisfies Meta<typeof RoomInviteHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
|
||||
const RoomInviteHeader = ({ room }: Pick<RoomHeaderProps, 'room'>) => {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteHeader;
|
||||
@ -0,0 +1,74 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInviteHeader renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-room-header rcx-css-7tefqp"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1apkof4"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-llee4e"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x36"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x36"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1axz7ym"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1yimpo4"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-i0csg7 rcx-css-f2vsf1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-v5o1rw"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-globe rcx-icon rcx-css-1t9h2ff"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<h1
|
||||
class="rcx-box rcx-box--full rcx-css-1w5kdwh"
|
||||
>
|
||||
rocket.cat
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--tiny-square rcx-button--square rcx-button--icon rcx-button rcx-css-sdt442"
|
||||
title="Favorite rocket.cat"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-star rcx-icon rcx-css-1g87xs3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
class="rcx-box rcx-box--full rcx-divider rcx-css-emj6cu"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@ -1,9 +1,10 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isVoipRoom } from '@rocket.chat/core-typings';
|
||||
import { isInviteSubscription, isVoipRoom } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
import { lazy, memo } from 'react';
|
||||
|
||||
const RoomInviteHeader = lazy(() => import('./RoomInviteHeader'));
|
||||
const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader'));
|
||||
const VoipRoomHeader = lazy(() => import('./Omnichannel/VoipRoomHeader'));
|
||||
const RoomHeaderE2EESetup = lazy(() => import('./RoomHeaderE2EESetup'));
|
||||
@ -11,9 +12,10 @@ const RoomHeader = lazy(() => import('./RoomHeader'));
|
||||
|
||||
type HeaderProps = {
|
||||
room: IRoom;
|
||||
subscription?: ISubscription;
|
||||
};
|
||||
|
||||
const Header = ({ room }: HeaderProps): ReactElement | null => {
|
||||
const Header = ({ room, subscription }: HeaderProps): ReactElement | null => {
|
||||
const { isEmbedded, showTopNavbarEmbeddedLayout } = useLayout();
|
||||
const encrypted = Boolean(room.encrypted);
|
||||
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
|
||||
@ -23,6 +25,10 @@ const Header = ({ room }: HeaderProps): ReactElement | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription && isInviteSubscription(subscription)) {
|
||||
return <RoomInviteHeader room={room} />;
|
||||
}
|
||||
|
||||
if (room.t === 'l') {
|
||||
return <OmnichannelRoomHeader />;
|
||||
}
|
||||
|
||||
65
apps/meteor/client/views/room/HeaderV2/RoomHeader.spec.tsx
Normal file
65
apps/meteor/client/views/room/HeaderV2/RoomHeader.spec.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ prid: undefined });
|
||||
const appRoot = mockAppRoot()
|
||||
.withRoom(mockedRoom)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={mockedRoom}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
jest.mock('../../../../app/utils/client', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomHeader', () => {
|
||||
describe('Toolbox', () => {
|
||||
it('should render toolbox by default', async () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{}} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render toolbox if roomToolbox is null and no slots are provided', () => {
|
||||
render(
|
||||
<RoomHeader
|
||||
room={mockedRoom}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render toolbox if slots.toolbox is provided', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: {} }} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from roomToolbox prop', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Custom Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Custom Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from slots.toolbox.content', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Slotted Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RoomInviteHeader.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
jest.mock('../../../../app/utils/client', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomInviteHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInviteHeader from './RoomInviteHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ name: 'rocket.cat', federated: true });
|
||||
|
||||
const meta = {
|
||||
component: RoomInviteHeader,
|
||||
args: {
|
||||
room: mockedRoom,
|
||||
},
|
||||
decorators: [(story) => <FakeRoomProvider roomOverrides={mockedRoom}>{story()}</FakeRoomProvider>],
|
||||
} satisfies Meta<typeof RoomInviteHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.tsx
Normal file
17
apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
|
||||
const RoomInviteHeader = ({ room }: Pick<RoomHeaderProps, 'room'>) => {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteHeader;
|
||||
@ -0,0 +1,63 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInviteHeader renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-room-header rcx-css-7tefqp"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1apkof4"
|
||||
>
|
||||
<div>
|
||||
ParentRoom
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1axz7ym"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1yimpo4"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-i0csg7 rcx-css-f2vsf1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-v5o1rw"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-globe rcx-icon rcx-css-1t9h2ff"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<h1
|
||||
class="rcx-box rcx-box--full rcx-css-1w5kdwh"
|
||||
>
|
||||
rocket.cat
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--tiny-square rcx-button--square rcx-button--icon rcx-button rcx-css-sdt442"
|
||||
title="Favorite rocket.cat"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-star rcx-icon rcx-css-1g87xs3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
class="rcx-box rcx-box--full rcx-divider rcx-css-emj6cu"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@ -1,17 +1,20 @@
|
||||
import { isInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn, ContextualbarSkeleton } from '@rocket.chat/ui-client';
|
||||
import { useTranslation, useSetting, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useSetting, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
import { createElement, lazy, memo, Suspense } from 'react';
|
||||
import { FocusScope } from 'react-aria';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RoomE2EESetup from './E2EESetup/RoomE2EESetup';
|
||||
import Header from './Header';
|
||||
import { HeaderV2 } from './HeaderV2';
|
||||
import MessageHighlightProvider from './MessageList/providers/MessageHighlightProvider';
|
||||
import RoomInvite from './RoomInvite';
|
||||
import RoomBody from './body/RoomBody';
|
||||
import RoomBodyV2 from './body/RoomBodyV2';
|
||||
import { useRoom } from './contexts/RoomContext';
|
||||
import { useRoom, useRoomSubscription } from './contexts/RoomContext';
|
||||
import { useAppsContextualBar } from './hooks/useAppsContextualBar';
|
||||
import RoomLayout from './layout/RoomLayout';
|
||||
import ChatProvider from './providers/ChatProvider';
|
||||
@ -21,13 +24,24 @@ import { SelectedMessagesProvider } from './providers/SelectedMessagesProvider';
|
||||
const UiKitContextualBar = lazy(() => import('./contextualBar/uikit/UiKitContextualBar'));
|
||||
|
||||
const Room = (): ReactElement => {
|
||||
const t = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const room = useRoom();
|
||||
const subscription = useRoomSubscription();
|
||||
const toolbox = useRoomToolbox();
|
||||
const contextualBarView = useAppsContextualBar();
|
||||
const isE2EEnabled = useSetting('E2E_Enable');
|
||||
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages');
|
||||
const shouldDisplayE2EESetup = room?.encrypted && !unencryptedMessagesAllowed && isE2EEnabled;
|
||||
const roomLabel =
|
||||
room.t === 'd' ? t('Conversation_with__roomName__', { roomName: room.name }) : t('Channel__roomName__', { roomName: room.name });
|
||||
|
||||
if (subscription && isInviteSubscription(subscription)) {
|
||||
return (
|
||||
<FocusScope>
|
||||
<RoomInvite room={room} subscription={subscription} data-qa-rc-room={room._id} aria-label={roomLabel} />
|
||||
</FocusScope>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatProvider>
|
||||
@ -36,22 +50,16 @@ const Room = (): ReactElement => {
|
||||
<DateListProvider>
|
||||
<RoomLayout
|
||||
data-qa-rc-room={room._id}
|
||||
aria-label={
|
||||
room.t === 'd'
|
||||
? t('Conversation_with__roomName__', { roomName: room.name })
|
||||
: t('Channel__roomName__', { roomName: room.name })
|
||||
}
|
||||
aria-label={roomLabel}
|
||||
header={
|
||||
<>
|
||||
<FeaturePreview feature='newNavigation'>
|
||||
<FeaturePreviewOn>
|
||||
<HeaderV2 room={room} />
|
||||
</FeaturePreviewOn>
|
||||
<FeaturePreviewOff>
|
||||
<Header room={room} />
|
||||
</FeaturePreviewOff>
|
||||
</FeaturePreview>
|
||||
</>
|
||||
<FeaturePreview feature='newNavigation'>
|
||||
<FeaturePreviewOn>
|
||||
<HeaderV2 room={room} subscription={subscription} />
|
||||
</FeaturePreviewOn>
|
||||
<FeaturePreviewOff>
|
||||
<Header room={room} subscription={subscription} />
|
||||
</FeaturePreviewOff>
|
||||
</FeaturePreview>
|
||||
}
|
||||
body={
|
||||
shouldDisplayE2EESetup ? (
|
||||
|
||||
51
apps/meteor/client/views/room/RoomInvite.tsx
Normal file
51
apps/meteor/client/views/room/RoomInvite.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { isRoomFederated, type IInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Header from './Header';
|
||||
import { HeaderV2 } from './HeaderV2';
|
||||
import RoomInviteBody from './body/RoomInviteBody';
|
||||
import type { IRoomWithFederationOriginalName } from './contexts/RoomContext';
|
||||
import { useRoomInvitation } from './hooks/useRoomInvitation';
|
||||
import RoomLayout from './layout/RoomLayout';
|
||||
import { links } from '../../lib/links';
|
||||
|
||||
type RoomInviteProps = Omit<ComponentProps<typeof RoomLayout>, 'header' | 'body' | 'aside'> & {
|
||||
room: IRoomWithFederationOriginalName;
|
||||
subscription: IInviteSubscription;
|
||||
};
|
||||
|
||||
const RoomInvite = ({ room, subscription, ...props }: RoomInviteProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { acceptInvite, rejectInvite, isPending } = useRoomInvitation(room);
|
||||
|
||||
const infoLink = isRoomFederated(room) ? { label: t('Learn_more_about_Federation'), href: links.go.matrixFederation } : undefined;
|
||||
|
||||
return (
|
||||
<RoomLayout
|
||||
{...props}
|
||||
header={
|
||||
<FeaturePreview feature='newNavigation'>
|
||||
<FeaturePreviewOn>
|
||||
<HeaderV2 room={room} subscription={subscription} />
|
||||
</FeaturePreviewOn>
|
||||
<FeaturePreviewOff>
|
||||
<Header room={room} subscription={subscription} />
|
||||
</FeaturePreviewOff>
|
||||
</FeaturePreview>
|
||||
}
|
||||
body={
|
||||
<RoomInviteBody
|
||||
inviter={subscription.inviter}
|
||||
infoLink={infoLink}
|
||||
isLoading={isPending}
|
||||
onAccept={acceptInvite}
|
||||
onReject={rejectInvite}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInvite;
|
||||
56
apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx
Normal file
56
apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import RoomInvite from './RoomInviteBody';
|
||||
import * as stories from './RoomInviteBody.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
describe('RoomInvite', () => {
|
||||
const onAccept = jest.fn();
|
||||
const onReject = jest.fn();
|
||||
const inviter = {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should call onAccept when accept button is clicked', async () => {
|
||||
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalled();
|
||||
expect(onReject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onReject when reject button is clicked', async () => {
|
||||
render(<RoomInvite inviter={inviter} onAccept={onAccept} onReject={onReject} />, { wrapper: appRoot });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reject' }));
|
||||
|
||||
expect(onReject).toHaveBeenCalled();
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInvite from './RoomInviteBody';
|
||||
|
||||
const meta = {
|
||||
component: RoomInvite,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onAccept: action('onAccept'),
|
||||
onReject: action('onReject'),
|
||||
},
|
||||
} satisfies Meta<typeof RoomInvite>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInfoLink: Story = {
|
||||
args: {
|
||||
infoLink: {
|
||||
label: 'Learn more',
|
||||
href: 'https://rocket.chat',
|
||||
},
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
inviter: {
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket Cat',
|
||||
_id: 'rocket.cat',
|
||||
},
|
||||
},
|
||||
};
|
||||
46
apps/meteor/client/views/room/body/RoomInviteBody.tsx
Normal file
46
apps/meteor/client/views/room/body/RoomInviteBody.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import type { IInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, Chip, States, StatesActions, StatesIcon, StatesLink, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type RoomInviteBodyProps = {
|
||||
isLoading?: boolean;
|
||||
inviter: IInviteSubscription['inviter'];
|
||||
infoLink?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
const RoomInviteBody = ({ inviter, infoLink, isLoading, onAccept, onReject }: RoomInviteBodyProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { name, username } = inviter;
|
||||
|
||||
return (
|
||||
<Box m='auto'>
|
||||
<States>
|
||||
<StatesIcon name='mail' />
|
||||
<StatesTitle>{t('Message_request')}</StatesTitle>
|
||||
<StatesSubtitle>
|
||||
<Box mbe={8}>{t('You_have_been_invited_to_have_a_conversation_with')}</Box>
|
||||
<Chip>
|
||||
<UserAvatar username={username} size='x16' /> {name || username}
|
||||
</Chip>
|
||||
</StatesSubtitle>
|
||||
<StatesActions>
|
||||
<Button secondary danger loading={isLoading} onClick={onReject}>
|
||||
{t('Reject')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onClick={onAccept}>
|
||||
{t('Accept')}
|
||||
</Button>
|
||||
</StatesActions>
|
||||
{infoLink && <StatesLink href={infoLink.href}>{infoLink.label}</StatesLink>}
|
||||
</States>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteBody;
|
||||
@ -0,0 +1,273 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInvite renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button rcx-button-group__item"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RoomInvite renders Loading without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--loading rcx-button--secondary-danger rcx-button rcx-button-group__item"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--loading rcx-button rcx-button-group__item"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-loading rcx-icon rcx-css-1hdf9ok"
|
||||
>
|
||||
|
||||
</i>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RoomInvite renders WithInfoLink without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-b4escz"
|
||||
>
|
||||
<div
|
||||
class="rcx-states"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-states__icon rcx-icon--name-mail rcx-icon rcx-css-d884y5"
|
||||
>
|
||||
|
||||
</i>
|
||||
<h3
|
||||
class="rcx-states__title"
|
||||
>
|
||||
Message_request
|
||||
</h3>
|
||||
<div
|
||||
class="rcx-states__subtitle"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-ctk2ij"
|
||||
>
|
||||
You_have_been_invited_to_have_a_conversation_with
|
||||
</div>
|
||||
<button
|
||||
class="rcx-box rcx-chip"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-chip__text rcx-css-trljwa"
|
||||
>
|
||||
<figure
|
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x16"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="rcx-avatar__element rcx-avatar__element--x16"
|
||||
data-username="rocket.cat"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg=="
|
||||
title="rocket.cat"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
Rocket Cat
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--secondary-danger rcx-button rcx-button-group__item"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Accept
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
class="rcx-box rcx-box--full rcx-states__link"
|
||||
href="https://rocket.chat"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@ -0,0 +1,6 @@
|
||||
import type { IUser, IRole, SubscriptionStatus, UserStatus, Serialized } from '@rocket.chat/core-typings';
|
||||
|
||||
export type RoomMemberUser = Pick<Serialized<IUser>, 'username' | '_id' | 'name' | 'freeSwitchExtension' | 'federated' | 'createdAt'> & {
|
||||
roles?: IRole['_id'][];
|
||||
status?: UserStatus | SubscriptionStatus;
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings';
|
||||
import { isPublicRoom, isInviteSubscription, type IRoom, type RoomType } from '@rocket.chat/core-typings';
|
||||
import { getObjectKeys } from '@rocket.chat/tools';
|
||||
import { useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@ -24,7 +24,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st
|
||||
|
||||
const result = useQuery({
|
||||
// we need to add uid and username here because `user` is not loaded all at once (see UserProvider -> Meteor.user())
|
||||
queryKey: ['rooms', { reference, type }, { uid: user?._id, username: user?.username }] as const,
|
||||
queryKey: roomsQueryKeys.roomReference(reference, type, user?._id, user?.username),
|
||||
|
||||
queryFn: async (): Promise<{ rid: IRoom['_id'] }> => {
|
||||
if ((user && !user.username) || (!user && !allowAnonymousRead)) {
|
||||
@ -35,6 +35,14 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st
|
||||
throw new RoomNotFoundError(undefined, { type, reference });
|
||||
}
|
||||
|
||||
const { Rooms, Subscriptions } = await import('../../../stores');
|
||||
|
||||
const sub = Subscriptions.state.find((record) => record.rid === reference || record.name === reference);
|
||||
|
||||
if (sub && isInviteSubscription(sub)) {
|
||||
return { rid: sub.rid };
|
||||
}
|
||||
|
||||
let roomData: IRoom;
|
||||
try {
|
||||
roomData = await getRoomByTypeAndName(type, reference);
|
||||
@ -58,8 +66,6 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st
|
||||
throw new RoomNotFoundError(undefined, { type, reference });
|
||||
}
|
||||
|
||||
const { Rooms, Subscriptions } = await import('../../../stores');
|
||||
|
||||
const unsetKeys = getObjectKeys(roomData).filter((key) => !(key in roomFields));
|
||||
unsetKeys.forEach((key) => {
|
||||
delete roomData[key];
|
||||
@ -83,8 +89,6 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st
|
||||
|
||||
const { RoomManager } = await import('../../../lib/RoomManager');
|
||||
|
||||
const sub = Subscriptions.state.find((record) => record.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 });
|
||||
|
||||
100
apps/meteor/client/views/room/hooks/useRoomInvitation.spec.tsx
Normal file
100
apps/meteor/client/views/room/hooks/useRoomInvitation.spec.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
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());
|
||||
});
|
||||
|
||||
it('should redirect to /home after rejecting an invite', async () => {
|
||||
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
|
||||
|
||||
act(() => void result.current.rejectInvite());
|
||||
|
||||
await waitFor(() => expect(mockedNavigate).toHaveBeenCalledWith('/home'));
|
||||
});
|
||||
|
||||
it('should not redirect to /home after accepting an invite', async () => {
|
||||
const { result } = renderHook(() => useRoomInvitation(mockedRoom), { wrapper: appRoot });
|
||||
|
||||
act(() => void result.current.acceptInvite());
|
||||
|
||||
await waitFor(() => expect(mockedNavigate).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
41
apps/meteor/client/views/room/hooks/useRoomInvitation.tsx
Normal file
41
apps/meteor/client/views/room/hooks/useRoomInvitation.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useRouter, useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useRoomRejectInvitationModal } from './useRoomRejectInvitationModal';
|
||||
import { useEndpointMutation } from '../../../hooks/useEndpointMutation';
|
||||
import { roomsQueryKeys } from '../../../lib/queryKeys';
|
||||
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
|
||||
|
||||
export const useRoomInvitation = (room: IRoomWithFederationOriginalName) => {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const { open: openConfirmationModal } = useRoomRejectInvitationModal(room);
|
||||
|
||||
const replyInvite = useEndpointMutation('POST', '/v1/rooms.invite', {
|
||||
onSuccess: async (_, { action }) => {
|
||||
const reference = room.federationOriginalName ?? room.name;
|
||||
|
||||
if (reference) {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: roomsQueryKeys.roomReference(reference, room.t, user?._id, user?.username),
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.room(room._id) });
|
||||
|
||||
if (action === 'reject') {
|
||||
router.navigate('/home');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
};
|
||||
@ -1,7 +1,7 @@
|
||||
import { Authorization } from '@rocket.chat/core-services';
|
||||
import type { RoomAccessValidator } from '@rocket.chat/core-services';
|
||||
import { TEAM_TYPE } from '@rocket.chat/core-typings';
|
||||
import type { IUser, ITeam } from '@rocket.chat/core-typings';
|
||||
import { TEAM_TYPE } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Rooms, Settings, TeamMember, Team } from '@rocket.chat/models';
|
||||
|
||||
import { canAccessRoomLivechat } from './canAccessRoomLivechat';
|
||||
|
||||
@ -249,7 +249,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService {
|
||||
userMentions: 1,
|
||||
groupMentions: 0,
|
||||
...(status && { status }),
|
||||
...(inviter && { inviter: { _id: inviter._id, username: inviter.username, name: inviter.name } }),
|
||||
...(inviter && { inviter: { _id: inviter._id, username: inviter.username!, name: inviter.name } }),
|
||||
...autoTranslateConfig,
|
||||
...getDefaultSubscriptionPref(userToBeAdded),
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@ const FakeRoomProvider = ({ children, roomOverrides, subscriptionOverrides }: Fa
|
||||
<RoomContext.Provider
|
||||
value={useMemo(() => {
|
||||
const room = createFakeRoom(roomOverrides);
|
||||
const subscription = faker.datatype.boolean() ? createFakeSubscription(subscriptionOverrides) : undefined;
|
||||
const subscription = createFakeSubscription(subscriptionOverrides);
|
||||
|
||||
return {
|
||||
rid: room._id,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
function createDeferredPromise<R = void>() {
|
||||
export function createDeferredPromise<R = void>() {
|
||||
let resolve!: (value: R | PromiseLike<R>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
|
||||
@ -75,7 +75,12 @@ export interface ISubscription extends IRocketChatRecord {
|
||||
suggestedOldRoomKeys?: OldKey[];
|
||||
|
||||
status?: SubscriptionStatus;
|
||||
inviter?: Pick<IUser, '_id' | 'username' | 'name'>;
|
||||
inviter?: Required<Pick<IUser, '_id' | 'username'>> & Pick<IUser, 'name'>;
|
||||
}
|
||||
|
||||
export interface IInviteSubscription extends ISubscription {
|
||||
status: 'INVITED';
|
||||
inviter: NonNullable<ISubscription['inviter']>;
|
||||
}
|
||||
|
||||
export interface IOmnichannelSubscription extends ISubscription {
|
||||
@ -85,3 +90,7 @@ export interface IOmnichannelSubscription extends ISubscription {
|
||||
export interface ISubscriptionDirectMessage extends Omit<ISubscription, 'name'> {
|
||||
t: 'd';
|
||||
}
|
||||
|
||||
export const isInviteSubscription = (subscription: ISubscription): subscription is IInviteSubscription => {
|
||||
return subscription?.status === 'INVITED' && !!subscription.inviter;
|
||||
};
|
||||
|
||||
@ -3021,6 +3021,8 @@
|
||||
"Learn_more_about_triggers": "Learn more about triggers",
|
||||
"Learn_more_about_units": "Learn more about units",
|
||||
"Learn_more_about_voice_channel": "Learn more about voice channel",
|
||||
"You_have_been_invited_to_have_a_conversation_with": "You've been invited to have a conversation with",
|
||||
"Learn_more_about_Federation": "Learn more about Federation",
|
||||
"Least_recent_updated": "Least recent updated",
|
||||
"Leave": "Leave",
|
||||
"Leave_Group_Warning": "Are you sure you want to leave the group \"{{roomName}}\"?",
|
||||
@ -3448,6 +3450,7 @@
|
||||
"Message_list": "Message list",
|
||||
"Message_pinning": "Message pinning",
|
||||
"Message_removed": "message removed",
|
||||
"Message_request": "Message request",
|
||||
"Message_sent": "Message sent",
|
||||
"Message_sent_by_email": "Message sent by Email",
|
||||
"Message_starring": "Message starring",
|
||||
@ -4335,6 +4338,9 @@
|
||||
"Registration_via_Admin": "Registration via Admin",
|
||||
"Regular_Expressions": "Regular Expressions",
|
||||
"Reject_call": "Reject call",
|
||||
"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.",
|
||||
"Release": "Release",
|
||||
"Releases": "Releases",
|
||||
"Religious": "Religious",
|
||||
@ -5403,6 +5409,7 @@
|
||||
"Unknown_Import_State": "Unknown Import State",
|
||||
"Unknown_User": "Unknown User",
|
||||
"Unknown_contact_callout_description": "Unknown contact. This contact is not on the contact list.",
|
||||
"unknown": "unknown",
|
||||
"Unlimited": "Unlimited",
|
||||
"Unlimited_MACs": "Unlimited MACs",
|
||||
"Unlimited_push_notifications": "Unlimited push notifications",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user