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:
Aleksander Nicacio da Silva 2025-12-09 23:53:49 -03:00 committed by GitHub
parent a22df0948a
commit 176d5eae3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1362 additions and 37 deletions

View File

@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/i18n": patch
---
Adds invitation request support to rooms

View File

@ -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,

View File

@ -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,

View File

@ -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} />;
}

View 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();
});
});
});

View File

@ -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();
});
});

View File

@ -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 = {};

View 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;

View File

@ -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>
`;

View File

@ -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 />;
}

View 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();
});
});
});

View File

@ -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();
});
});

View File

@ -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 = {};

View 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;

View File

@ -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>
`;

View File

@ -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 ? (

View 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;

View 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();
});
});

View File

@ -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',
},
},
};

View 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;

View File

@ -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>
`;

View File

@ -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;
};

View File

@ -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 });

View 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());
});
});

View 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' });
},
};
};

View File

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

View File

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

View File

@ -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';

View File

@ -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),
});

View File

@ -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,

View File

@ -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;

View File

@ -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;
};

View File

@ -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",