diff --git a/.changeset/chatty-roses-help.md b/.changeset/chatty-roses-help.md new file mode 100644 index 0000000000000..3c4a5849c8daa --- /dev/null +++ b/.changeset/chatty-roses-help.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/i18n": patch +--- + +Adds invitation request support to rooms diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index b6ff87a87b2b1..9b301c940c6b3 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -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, diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index a7a8cdf655abb..e6046a6dd07d0 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -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, diff --git a/apps/meteor/client/views/room/Header/Header.tsx b/apps/meteor/client/views/room/Header/Header.tsx index 70f81ed914230..9f10555d26a7e 100644 --- a/apps/meteor/client/views/room/Header/Header.tsx +++ b/apps/meteor/client/views/room/Header/Header.tsx @@ -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 = { room: T; + subscription?: ISubscription; }; -const Header = ({ room }: HeaderProps): ReactElement | null => { +const Header = ({ room, subscription }: HeaderProps): 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): ReactElement | null => { return null; } + if (subscription && isInviteSubscription(subscription)) { + return ; + } + if (room.t === 'l') { return ; } diff --git a/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx b/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx new file mode 100644 index 0000000000000..2d1b78b335033 --- /dev/null +++ b/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx @@ -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) => {children}) + .build(); + +jest.mock('../../../../app/utils/client', () => ({ + getURL: (url: string) => url, +})); + +jest.mock('./ParentRoomWithData', () => ({ + __esModule: true, + default: jest.fn(() =>
ParentRoomWithData
), +})); + +jest.mock('./ParentTeam', () => ({ + __esModule: true, + default: jest.fn(() =>
ParentTeam
), +})); + +jest.mock('./RoomToolbox', () => ({ + __esModule: true, + default: jest.fn(() =>
RoomToolbox
), +})); + +describe('RoomHeader', () => { + describe('Toolbox', () => { + it('should render toolbox by default', async () => { + render(, { wrapper: appRoot }); + expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument(); + }); + + it('should not render toolbox if roomToolbox is null and no slots are provided', () => { + render( + , + { wrapper: appRoot }, + ); + expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument(); + }); + + it('should render toolbox if slots.toolbox is provided', () => { + render(, { wrapper: appRoot }); + expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument(); + }); + + it('should render custom toolbox content from roomToolbox prop', () => { + render( + Custom Toolbox, + }, + }} + />, + { wrapper: appRoot }, + ); + expect(screen.getByText('Custom Toolbox')).toBeInTheDocument(); + }); + + it('should render custom toolbox content from slots.toolbox.content', () => { + render(Slotted Toolbox } }} />, { wrapper: appRoot }); + expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/meteor/client/views/room/Header/RoomInviteHeader.spec.tsx b/apps/meteor/client/views/room/Header/RoomInviteHeader.spec.tsx new file mode 100644 index 0000000000000..299e7a522f656 --- /dev/null +++ b/apps/meteor/client/views/room/Header/RoomInviteHeader.spec.tsx @@ -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(() =>
ParentRoomWithData
), +})); + +jest.mock('./ParentTeam', () => ({ + __esModule: true, + default: jest.fn(() =>
ParentTeam
), +})); + +jest.mock('./RoomToolbox', () => ({ + __esModule: true, + default: jest.fn(() =>
RoomToolbox
), +})); + +describe('RoomInviteHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: appRoot }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: appRoot }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/apps/meteor/client/views/room/Header/RoomInviteHeader.stories.tsx b/apps/meteor/client/views/room/Header/RoomInviteHeader.stories.tsx new file mode 100644 index 0000000000000..5603fdf28efef --- /dev/null +++ b/apps/meteor/client/views/room/Header/RoomInviteHeader.stories.tsx @@ -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) => {story()}], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/room/Header/RoomInviteHeader.tsx b/apps/meteor/client/views/room/Header/RoomInviteHeader.tsx new file mode 100644 index 0000000000000..5c309bbe74fa6 --- /dev/null +++ b/apps/meteor/client/views/room/Header/RoomInviteHeader.tsx @@ -0,0 +1,17 @@ +import RoomHeader from './RoomHeader'; +import type { RoomHeaderProps } from './RoomHeader'; + +const RoomInviteHeader = ({ room }: Pick) => { + return ( + + ); +}; + +export default RoomInviteHeader; diff --git a/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap b/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap new file mode 100644 index 0000000000000..77576d93e6792 --- /dev/null +++ b/apps/meteor/client/views/room/Header/__snapshots__/RoomInviteHeader.spec.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomInviteHeader renders Default without crashing 1`] = ` + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+

+ rocket.cat +

+
+ +
+
+
+
+
+
+ +`; diff --git a/apps/meteor/client/views/room/HeaderV2/Header.tsx b/apps/meteor/client/views/room/HeaderV2/Header.tsx index 067abcd88f05c..7362e2587af6e 100644 --- a/apps/meteor/client/views/room/HeaderV2/Header.tsx +++ b/apps/meteor/client/views/room/HeaderV2/Header.tsx @@ -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 ; + } + if (room.t === 'l') { return ; } diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeader.spec.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeader.spec.tsx new file mode 100644 index 0000000000000..3db80cf37ce76 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeader.spec.tsx @@ -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) => {children}) + .build(); + +jest.mock('../../../../app/utils/client', () => ({ + getURL: (url: string) => url, +})); + +jest.mock('./ParentRoom', () => ({ + __esModule: true, + default: jest.fn(() =>
ParentRoom
), +})); + +jest.mock('./RoomToolbox', () => ({ + __esModule: true, + default: jest.fn(() =>
RoomToolbox
), +})); + +describe('RoomHeader', () => { + describe('Toolbox', () => { + it('should render toolbox by default', async () => { + render(, { wrapper: appRoot }); + expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument(); + }); + + it('should not render toolbox if roomToolbox is null and no slots are provided', () => { + render( + , + { wrapper: appRoot }, + ); + expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument(); + }); + + it('should render toolbox if slots.toolbox is provided', () => { + render(, { wrapper: appRoot }); + expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument(); + }); + + it('should render custom toolbox content from roomToolbox prop', () => { + render(Custom Toolbox } }} />, { wrapper: appRoot }); + expect(screen.getByText('Custom Toolbox')).toBeInTheDocument(); + }); + + it('should render custom toolbox content from slots.toolbox.content', () => { + render(Slotted Toolbox } }} />, { wrapper: appRoot }); + expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.spec.tsx b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.spec.tsx new file mode 100644 index 0000000000000..46c9b55b88725 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.spec.tsx @@ -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(() =>
ParentRoom
), +})); + +jest.mock('./RoomToolbox', () => ({ + __esModule: true, + default: jest.fn(() =>
RoomToolbox
), +})); + +describe('RoomInviteHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(, { wrapper: appRoot }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: appRoot }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.stories.tsx b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.stories.tsx new file mode 100644 index 0000000000000..5603fdf28efef --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.stories.tsx @@ -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) => {story()}], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.tsx b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.tsx new file mode 100644 index 0000000000000..5c309bbe74fa6 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomInviteHeader.tsx @@ -0,0 +1,17 @@ +import RoomHeader from './RoomHeader'; +import type { RoomHeaderProps } from './RoomHeader'; + +const RoomInviteHeader = ({ room }: Pick) => { + return ( + + ); +}; + +export default RoomInviteHeader; diff --git a/apps/meteor/client/views/room/HeaderV2/__snapshots__/RoomInviteHeader.spec.tsx.snap b/apps/meteor/client/views/room/HeaderV2/__snapshots__/RoomInviteHeader.spec.tsx.snap new file mode 100644 index 0000000000000..9b0ccce5ab558 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/__snapshots__/RoomInviteHeader.spec.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomInviteHeader renders Default without crashing 1`] = ` + +
+
+
+
+ ParentRoom +
+
+
+
+
+ +
+

+ rocket.cat +

+
+ +
+
+
+
+
+
+ +`; diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index 7ea00c277e42c..10c8ca831762f 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -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 ( + + + + ); + } return ( @@ -36,22 +50,16 @@ const Room = (): ReactElement => { - - - - - -
- - - + + + + + +
+ + } body={ shouldDisplayE2EESetup ? ( diff --git a/apps/meteor/client/views/room/RoomInvite.tsx b/apps/meteor/client/views/room/RoomInvite.tsx new file mode 100644 index 0000000000000..97fe37201ca58 --- /dev/null +++ b/apps/meteor/client/views/room/RoomInvite.tsx @@ -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, '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 ( + + + + + +
+ + + } + body={ + + } + /> + ); +}; + +export default RoomInvite; diff --git a/apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx b/apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx new file mode 100644 index 0000000000000..6e1787cfb31fb --- /dev/null +++ b/apps/meteor/client/views/room/body/RoomInviteBody.spec.tsx @@ -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(, { wrapper: appRoot }); + expect(view.baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: appRoot }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should call onAccept when accept button is clicked', async () => { + render(, { 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(, { wrapper: appRoot }); + + await userEvent.click(screen.getByRole('button', { name: 'Reject' })); + + expect(onReject).toHaveBeenCalled(); + expect(onAccept).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/views/room/body/RoomInviteBody.stories.tsx b/apps/meteor/client/views/room/body/RoomInviteBody.stories.tsx new file mode 100644 index 0000000000000..7e131a441f640 --- /dev/null +++ b/apps/meteor/client/views/room/body/RoomInviteBody.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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', + }, + }, +}; diff --git a/apps/meteor/client/views/room/body/RoomInviteBody.tsx b/apps/meteor/client/views/room/body/RoomInviteBody.tsx new file mode 100644 index 0000000000000..763f5b3c1195f --- /dev/null +++ b/apps/meteor/client/views/room/body/RoomInviteBody.tsx @@ -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 ( + + + + {t('Message_request')} + + {t('You_have_been_invited_to_have_a_conversation_with')} + + {name || username} + + + + + + + {infoLink && {infoLink.label}} + + + ); +}; + +export default RoomInviteBody; diff --git a/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap b/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap new file mode 100644 index 0000000000000..80bed86156878 --- /dev/null +++ b/apps/meteor/client/views/room/body/__snapshots__/RoomInviteBody.spec.tsx.snap @@ -0,0 +1,273 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomInvite renders Default without crashing 1`] = ` + +
+
+
+ +

+ Message_request +

+
+
+ You_have_been_invited_to_have_a_conversation_with +
+ +
+
+ + +
+
+
+
+ +`; + +exports[`RoomInvite renders Loading without crashing 1`] = ` + +
+
+
+ +

+ Message_request +

+
+
+ You_have_been_invited_to_have_a_conversation_with +
+ +
+
+ + +
+
+
+
+ +`; + +exports[`RoomInvite renders WithInfoLink without crashing 1`] = ` + +
+
+
+ +

+ Message_request +

+
+
+ You_have_been_invited_to_have_a_conversation_with +
+ +
+
+ + +
+ + Learn more + +
+
+
+ +`; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts b/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts new file mode 100644 index 0000000000000..d4dfa2906c7b3 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts @@ -0,0 +1,6 @@ +import type { IUser, IRole, SubscriptionStatus, UserStatus, Serialized } from '@rocket.chat/core-typings'; + +export type RoomMemberUser = Pick, 'username' | '_id' | 'name' | 'freeSwitchExtension' | 'federated' | 'createdAt'> & { + roles?: IRole['_id'][]; + status?: UserStatus | SubscriptionStatus; +}; diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index bfd65af1daae4..cc1b87209272c 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -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 }); diff --git a/apps/meteor/client/views/room/hooks/useRoomInvitation.spec.tsx b/apps/meteor/client/views/room/hooks/useRoomInvitation.spec.tsx new file mode 100644 index 0000000000000..30a7f73e75ed4 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useRoomInvitation.spec.tsx @@ -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()); + }); +}); diff --git a/apps/meteor/client/views/room/hooks/useRoomInvitation.tsx b/apps/meteor/client/views/room/hooks/useRoomInvitation.tsx new file mode 100644 index 0000000000000..7989fdf3291ed --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useRoomInvitation.tsx @@ -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' }); + }, + }; +}; diff --git a/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.spec.tsx b/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.spec.tsx new file mode 100644 index 0000000000000..55221137ce178 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.spec.tsx @@ -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(); + }); +}); diff --git a/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.tsx b/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.tsx new file mode 100644 index 0000000000000..fd424b16bfa3a --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useRoomRejectInvitationModal.tsx @@ -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; + 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((resolve) => { + setModal( + { + resolve(true); + setModal(null); + }} + onCancel={() => { + resolve(false); + close(); + }} + > + {description} + , + ); + }), + [close, description, setModal, t], + ); + + return { open, close }; +}; diff --git a/apps/meteor/server/services/authorization/canAccessRoom.ts b/apps/meteor/server/services/authorization/canAccessRoom.ts index 2182bef8e8873..289b21a02c7e3 100644 --- a/apps/meteor/server/services/authorization/canAccessRoom.ts +++ b/apps/meteor/server/services/authorization/canAccessRoom.ts @@ -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'; diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 968b380b0a984..b32abc80edb3d 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -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), }); diff --git a/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx b/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx index e69e47ac10fec..13ddf09759985 100644 --- a/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx +++ b/apps/meteor/tests/mocks/client/FakeRoomProvider.tsx @@ -17,7 +17,7 @@ const FakeRoomProvider = ({ children, roomOverrides, subscriptionOverrides }: Fa { const room = createFakeRoom(roomOverrides); - const subscription = faker.datatype.boolean() ? createFakeSubscription(subscriptionOverrides) : undefined; + const subscription = createFakeSubscription(subscriptionOverrides); return { rid: room._id, diff --git a/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts index d70e0083c0be6..96e187cec8ded 100644 --- a/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts +++ b/apps/meteor/tests/mocks/utils/createDeferredMockFn.ts @@ -1,4 +1,4 @@ -function createDeferredPromise() { +export function createDeferredPromise() { let resolve!: (value: R | PromiseLike) => void; let reject!: (reason?: unknown) => void; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index b984d2e06d6a6..73eae10004de0 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -75,7 +75,12 @@ export interface ISubscription extends IRocketChatRecord { suggestedOldRoomKeys?: OldKey[]; status?: SubscriptionStatus; - inviter?: Pick; + inviter?: Required> & Pick; +} + +export interface IInviteSubscription extends ISubscription { + status: 'INVITED'; + inviter: NonNullable; } export interface IOmnichannelSubscription extends ISubscription { @@ -85,3 +90,7 @@ export interface IOmnichannelSubscription extends ISubscription { export interface ISubscriptionDirectMessage extends Omit { t: 'd'; } + +export const isInviteSubscription = (subscription: ISubscription): subscription is IInviteSubscription => { + return subscription?.status === 'INVITED' && !!subscription.inviter; +}; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c010eb8b4f2bb..ce902b70ee484 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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",