snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

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

View File

@@ -0,0 +1,110 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import ABACUpsellModal from './ABACUpsellModal';
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
jest.mock('../../GenericUpsellModal/hooks', () => ({
useUpsellActions: jest.fn(() => ({
shouldShowUpsell: true,
cloudWorkspaceHadTrial: false,
handleManageSubscription: jest.fn(),
handleTalkToSales: jest.fn(),
})),
}));
// Mock getURL utility
jest.mock('../../../../client/lib/getURL', () => ({
getURL: (url: string) => url,
}));
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Premium_capability: 'Premium capability',
Attribute_based_access_control: 'Attribute-Based Access Control',
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
Attribute_based_access_control_description:
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
Upgrade: 'Upgrade',
Cancel: 'Cancel',
})
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo(),
}))
.build();
describe('ABACUpsellModal', () => {
const mockOnClose = jest.fn();
const mockOnConfirm = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the modal with correct content', () => {
const { baseElement } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
expect(baseElement).toMatchSnapshot();
});
it('should have no accessibility violations', async () => {
const { container } = render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should call onConfirm when upgrade button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
await user.click(upgradeButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should call onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
// Look for close button (usually has aria-label or is the X button)
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should handle multiple button clicks correctly', async () => {
const user = userEvent.setup();
render(<ABACUpsellModal onClose={mockOnClose} onConfirm={mockOnConfirm} />, { wrapper: appRoot });
const upgradeButton = screen.getByRole('button', { name: 'Upgrade' });
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
// Click upgrade first
await user.click(upgradeButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
// Click cancel
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
// Total calls
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,42 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import ABACUpsellModal from './ABACUpsellModal';
const meta = {
component: ABACUpsellModal,
parameters: {
layout: 'centered',
},
args: {
onClose: action('onClose'),
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withTranslations('en', 'core', {
Attribute_based_access_control: 'Attribute-Based Access Control',
Attribute_based_access_control_title: 'Automate complex access management across your entire organization',
Attribute_based_access_control_description:
'ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.',
})
.build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
} satisfies Meta<typeof ABACUpsellModal>;
export default meta;
export const Default = {
args: {
onClose: action('onClose'),
onConfirm: action('onConfirm'),
},
};

View File

@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { getURL } from '../../../lib/getURL';
import GenericUpsellModal from '../../GenericUpsellModal';
export type ABACUpsellModalProps = {
onClose: () => void;
onConfirm: () => void;
};
const ABACUpsellModal = ({ onClose, onConfirm }: ABACUpsellModalProps) => {
const { t } = useTranslation();
return (
<GenericUpsellModal
tagline={t('Premium_capability')}
title={t('Attribute_based_access_control')}
subtitle={t('Attribute_based_access_control_title')}
description={t('Attribute_based_access_control_description')}
img={getURL('images/abac-upsell-modal.svg')}
onClose={onClose}
onConfirm={onConfirm}
onCancel={onClose}
imgHeight={256}
/>
);
};
export default ABACUpsellModal;

View File

@@ -0,0 +1,112 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ABACUpsellModal should render the modal with correct content 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Attribute-Based Access Control
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt=""
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1qfl5uy"
src="images/abac-upsell-modal.svg"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
Automate complex access management across your entire organization
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1,53 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUiKitActionManager } from '../uikit/hooks/useUiKitActionManager';
const ActionManagerBusyState = () => {
const { t } = useTranslation();
const actionManager = useUiKitActionManager();
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!actionManager) {
return;
}
const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy);
actionManager.on('busy', handleBusyStateChange);
return () => {
actionManager.off('busy', handleBusyStateChange);
};
}, [actionManager]);
if (busy) {
return (
<Box
className={css`
transform: translateX(-50%);
pointer-events: none;
`}
position='absolute'
insetInlineStart='50%'
padding={16}
backgroundColor='tint'
color='default'
textAlign='center'
fontSize='p2'
elevation='2'
borderRadius='0 0 4px 4px'
zIndex={99999}
>
{t('Loading')}
</Box>
);
}
return null;
};
export default ActionManagerBusyState;

View File

@@ -0,0 +1,31 @@
import { PaletteStyleTag } from '@rocket.chat/fuselage';
import { useDarkMode } from '@rocket.chat/fuselage-hooks';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export type AppLayoutThemeWrapperProps = { children: ReactNode };
const AppLayoutThemeWrapper = ({ children }: AppLayoutThemeWrapperProps) => {
const dark = useDarkMode();
/*
* Paint the root canvas so mobile browsers fill the overscroll/safe-area regions
* (and tint their toolbars) with the theme background instead of falling back to white.
*/
useEffect(() => {
document.documentElement.style.setProperty('background-color', 'var(--rcx-color-surface-tint)');
return () => {
document.documentElement.style.removeProperty('background-color');
};
}, []);
return (
<>
<PaletteStyleTag theme={dark ? 'dark' : 'light'} tagId='app-layout-palette' />
{children}
</>
);
};
export default AppLayoutThemeWrapper;

View File

@@ -0,0 +1,27 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useIdleActiveEvents } from '../hooks/useIdleActiveEvents';
export const AutoupdateToastMessage = () => {
const { t } = useTranslation();
useIdleActiveEvents({ id: 'autoupdate', awayOnWindowBlur: true }, () => {
window.location.reload();
});
return (
<Box
display='flex'
alignItems='center'
className={css`
gap: 8px;
`}
>
{t('An_update_is_available')}
<Button primary small onClick={() => window.location.reload()}>
{t('Reload_to_update')}
</Button>
</Box>
);
};

View File

@@ -0,0 +1,6 @@
import { ModalBackdrop } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type BackdropProps = ComponentProps<typeof ModalBackdrop>;
export const Backdrop = (props: BackdropProps) => <ModalBackdrop backgroundColor='transparent' {...props} />;

View File

@@ -0,0 +1,89 @@
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import type { ComponentPropsWithoutRef } from 'react';
import { Trans } from 'react-i18next';
export type ConfirmOwnerChangeModalProps = {
shouldChangeOwner: string[];
shouldBeRemoved: string[];
contentTitle?: string;
} & Pick<ComponentPropsWithoutRef<typeof GenericModal>, 'onConfirm' | 'onCancel' | 'confirmText'>;
const ConfirmOwnerChangeModal = ({
shouldChangeOwner,
shouldBeRemoved,
contentTitle,
confirmText,
onConfirm,
onCancel,
}: ConfirmOwnerChangeModalProps) => {
const getChangeOwnerRooms = useStableCallback(() => {
if (shouldChangeOwner.length === 0) {
return '';
}
if (shouldChangeOwner.length === 1) {
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to_the__roomName__room'
values={{ roomName: shouldChangeOwner[0] }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
if (shouldChangeOwner.length <= 5) {
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__'
values={{ count: shouldChangeOwner.length, rooms: shouldChangeOwner.join(', ') }}
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
return (
<Trans
i18nKey='A_new_owner_will_be_assigned_automatically_to__count__rooms'
values={{ count: shouldChangeOwner.length }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
});
const getRemovedRooms = useStableCallback(() => {
if (shouldBeRemoved.length === 0) {
return '';
}
if (shouldBeRemoved.length === 1) {
return (
<Trans
i18nKey='The_empty_room__roomName__will_be_removed_automatically'
values={{ roomName: shouldBeRemoved[0] }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
if (shouldBeRemoved.length <= 5) {
return (
<Trans
i18nKey='__count__empty_rooms_will_be_removed_automatically__rooms__'
values={{ count: shouldBeRemoved.length, rooms: shouldBeRemoved.join(', ') }}
components={{ br: <br />, bold: <Box is='span' fontWeight='bold' /> }}
/>
);
}
return <Trans i18nKey='__count__empty_rooms_will_be_removed_automatically' values={{ count: shouldBeRemoved.length }} />;
});
return (
<GenericModal variant='danger' onClose={onCancel} onCancel={onCancel} confirmText={confirmText} onConfirm={onConfirm}>
{contentTitle}
{shouldChangeOwner && <Box marginBlock={16}>{getChangeOwnerRooms()}</Box>}
{shouldBeRemoved && <Box marginBlock={16}>{getRemovedRooms()}</Box>}
</GenericModal>
);
};
export default ConfirmOwnerChangeModal;

View File

@@ -0,0 +1,97 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import CreateDiscussion from './CreateDiscussion';
import * as stories from './CreateDiscussion.stories';
import { createFakeRoom } from '../../../tests/mocks/data';
jest.mock('../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: () => ({
getRoomName: () => 'General',
}),
},
}));
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
const room1 = createFakeRoom({ encrypted: true, fname: 'Encrypted Room 1' });
const room2 = createFakeRoom({ encrypted: false, fname: 'Unencrypted Room 2' });
const appRoot = mockAppRoot()
.withEndpoint('POST', '/v1/rooms.createDiscussion', () => ({
success: true,
discussion: {
...createFakeRoom({
_id: 'discussion-id',
t: 'p' as const,
name: 'discussion-name',
fname: 'Discussion Name',
prid: 'parent-room-id',
}),
rid: 'discussion-id',
} as any,
}))
.withEndpoint(
'GET',
'/v1/rooms.autocomplete.channelAndPrivate',
() =>
({
items: [room1, room2],
}) as any,
)
.withTranslations('en', 'core', {
Encrypted: 'Encrypted',
Discussion_first_message_title: 'Message',
Discussion_target_channel: 'Parent channel or team',
})
.build();
describe('CreateDiscussion', () => {
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />, { wrapper: appRoot });
expect(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();
});
describe('Encrypted parent room behavior', () => {
it('should disable encrypted toggle and first message field when parent room is encrypted', () => {
render(<CreateDiscussion onClose={jest.fn()} encryptedParentRoom={true} />, { wrapper: appRoot });
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
expect(encryptedToggle).toBeDisabled();
const firstMessageField = screen.getByLabelText('Message');
expect(firstMessageField).toBeDisabled();
});
it('should disable encrypted toggle and first message field when an encrypted parent room is selected', async () => {
render(<CreateDiscussion onClose={jest.fn()} />, { wrapper: appRoot });
const parentRoomSelect = screen.getByRole('textbox', { name: 'Parent channel or team' });
await userEvent.click(parentRoomSelect);
await waitFor(() => {
expect(screen.getByText('Encrypted Room 1')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Encrypted Room 1'));
const encryptedToggle = screen.getByRole('checkbox', { name: 'Encrypted' });
expect(encryptedToggle).toBeDisabled();
const firstMessageField = screen.getByLabelText('Message');
expect(firstMessageField).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,16 @@
import type { StoryObj, Meta } from '@storybook/react';
import CreateDiscussion from './CreateDiscussion';
export default {
component: CreateDiscussion,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
args: {
onClose: () => undefined,
},
} satisfies Meta<typeof CreateDiscussion>;
export const Default: StoryObj<typeof CreateDiscussion> = {};

View File

@@ -0,0 +1,222 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { Icon, Box } from '@rocket.chat/fuselage';
import {
Field,
FieldGroup,
ToggleSwitch,
TextInput,
TextAreaInput,
FieldHint,
FieldLabel,
FieldRow,
FieldError,
} from '@rocket.chat/fuselage-forms';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useEncryptedRoomDescription } from '../../navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription';
import { useGoToRoom } from '../../views/room/hooks/useGoToRoom';
import RoomAutoComplete from '../RoomAutoComplete';
import UserAutoCompleteMultiple from '../UserAutoCompleteMultiple';
import DefaultParentRoomField from './DefaultParentRoomField';
type CreateDiscussionFormValues = {
name: string;
parentRoom: IRoom['_id'];
encrypted: boolean;
usernames: Array<IUser['username']>;
firstMessage: string;
topic: string;
};
export type CreateDiscussionProps = {
parentMessageId?: IMessage['_id'];
encryptedParentRoom?: boolean;
onClose: () => void;
onSuccess?: (rid: string) => void | Promise<void>;
defaultParentRoom?: IRoom['_id'];
nameSuggestion?: string;
};
const CreateDiscussion = ({
onClose,
onSuccess,
defaultParentRoom,
parentMessageId,
nameSuggestion,
encryptedParentRoom = false,
}: CreateDiscussionProps) => {
const t = useTranslation();
const [encryptedDisabled, setEncryptedDisabled] = useState(encryptedParentRoom);
const {
formState: { errors },
handleSubmit,
control,
watch,
setValue,
} = useForm({
defaultValues: {
name: nameSuggestion || '',
parentRoom: '',
encrypted: encryptedParentRoom,
usernames: [],
firstMessage: '',
topic: '',
},
});
const onParentRoomChange = useStableCallback((room: IRoom | undefined) => {
if (!room) {
return;
}
setValue('encrypted', room.encrypted === true);
setEncryptedDisabled(room.encrypted === true);
});
const { encrypted } = watch();
const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion');
const goToRoom = useGoToRoom();
const dispatchToastMessage = useToastMessageDispatch();
const createDiscussionMutation = useMutation({
mutationFn: createDiscussion,
onSuccess: ({ discussion }) => {
goToRoom(discussion._id);
void onSuccess?.(discussion._id);
onClose();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => {
createDiscussionMutation.mutate({
prid: defaultParentRoom || parentRoom,
t_name: name,
users: usernames,
reply: encrypted ? undefined : firstMessage,
topic,
...(parentMessageId && { pmid: parentMessageId }),
});
};
const getEncryptedHint = useEncryptedRoomDescription('discussion');
return (
<GenericModal
variant='warning'
icon={null}
title={t('Discussion_title')}
onCancel={onClose}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
confirmText={t('Create')}
cancelText={t('Cancel')}
confirmLoading={createDiscussionMutation.isPending}
>
<Box marginBlockEnd={24}>{t('Discussion_description')}</Box>
<FieldGroup>
<Field>
<FieldLabel required>{t('Discussion_target_channel')}</FieldLabel>
<FieldRow>
{defaultParentRoom && (
<Controller
control={control}
name='parentRoom'
render={() => <DefaultParentRoomField defaultParentRoom={defaultParentRoom} />}
/>
)}
{!defaultParentRoom && (
<Controller
control={control}
name='parentRoom'
rules={{ required: t('Required_field', { field: t('Discussion_target_channel') }) }}
render={({ field }) => (
<RoomAutoComplete
{...field}
error={Boolean(errors.parentRoom?.message)}
placeholder={t('Search_options')}
disabled={Boolean(defaultParentRoom)}
aria-required='true'
setSelectedRoom={onParentRoomChange}
renderRoomIcon={({ encrypted }) => (encrypted ? <Icon name='key' /> : null)}
/>
)}
/>
)}
</FieldRow>
{errors.parentRoom && <FieldError>{errors.parentRoom.message}</FieldError>}
</Field>
<Field>
<FieldLabel required>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
name='name'
control={control}
rules={{ required: t('Required_field', { field: t('Name') }) }}
render={({ field }) => (
<TextInput {...field} aria-required='true' endAddon={<Icon name='baloons' size='x20' />} error={errors.name?.message} />
)}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
</Field>
<Field>
<FieldLabel>{t('Topic')}</FieldLabel>
<FieldRow>
<Controller name='topic' control={control} render={({ field }) => <TextInput {...field} error={errors.topic?.message} />} />
</FieldRow>
<FieldRow>
<FieldHint>{t('Displayed_next_to_name')}</FieldHint>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Members')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='usernames'
render={({ field }) => <UserAutoCompleteMultiple {...field} placeholder={t('Add_people')} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Discussion_first_message_title')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='firstMessage'
render={({ field }) => <TextAreaInput {...field} rows={5} disabled={encrypted} />}
/>
</FieldRow>
{encrypted ? (
<FieldHint>{t('Discussion_first_message_disabled_due_to_e2e')}</FieldHint>
) : (
<FieldHint>{t('First_message_hint')}</FieldHint>
)}
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Encrypted')}</FieldLabel>
<Controller
control={control}
name='encrypted'
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} disabled={encryptedDisabled} />}
/>
</FieldRow>
<FieldHint>{getEncryptedHint({ isPrivate: true, encrypted })}</FieldHint>
</Field>
</FieldGroup>
</GenericModal>
);
};
export default CreateDiscussion;

View File

@@ -0,0 +1,54 @@
import { Skeleton, Callout } from '@rocket.chat/fuselage';
import { TextInput } from '@rocket.chat/fuselage-forms';
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentPropsWithoutRef } from 'react';
import { useMemo } from 'react';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
export type DefaultParentRoomFieldProps = {
defaultParentRoom: string;
} & Omit<ComponentPropsWithoutRef<typeof TextInput>, 'defaultValue' | 'disabled'>;
const DefaultParentRoomField = ({ defaultParentRoom, ...props }: DefaultParentRoomFieldProps) => {
const t = useTranslation();
const query = useMemo(
() => ({
roomId: defaultParentRoom,
}),
[defaultParentRoom],
);
const roomsInfoEndpoint = useEndpoint('GET', '/v1/rooms.info');
const { data, isPending, isError } = useQuery({
queryKey: ['defaultParentRoomInfo', query],
queryFn: async () => roomsInfoEndpoint(query),
refetchOnWindowFocus: false,
});
if (isPending) {
return <Skeleton width='full' />;
}
if (!data?.room || isError) {
return <Callout type='danger'>{t('Error')}</Callout>;
}
return (
<TextInput
{...props}
defaultValue={roomCoordinator.getRoomName(data.room.t, {
_id: data.room._id,
fname: data.room.fname,
name: data.room.name,
prid: data.room.prid,
})}
disabled
/>
);
};
export default DefaultParentRoomField;

View File

@@ -0,0 +1,347 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CreateDiscussion renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<form
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Discussion_title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<div
class="rcx-box rcx-box--full rcx-css-heem01"
>
Discussion_description
</div>
<fieldset
class="rcx-box rcx-box--full rcx-field-group"
role="group"
>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="react-aria-_r_1_-label"
>
Parent channel or team
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
role="group"
>
<input
aria-describedby=""
aria-invalid="false"
aria-labelledby="react-aria-_r_1_-label"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-rcil7k"
id="react-aria-_r_1_"
name="parentRoom"
placeholder="Search_options"
value=""
/>
</div>
<div
class="rcx-box rcx-box--full rcx-autocomplete__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
>
</i>
</div>
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_2_-label"
>
Name
<span
aria-hidden="true"
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
>
*
</span>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
>
<input
aria-describedby=""
aria-invalid="false"
aria-required="true"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box"
id="react-aria-_r_2_-label"
name="name"
size="1"
type="text"
value=""
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-baloons rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_3_-label"
>
Topic
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<input
aria-describedby="react-aria-_r_3_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
id="react-aria-_r_3_-label"
name="topic"
size="1"
type="text"
value=""
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_3_-hint"
>
Displayed_next_to_name
</span>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
id="react-aria-_r_4_-label"
>
Members
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-select"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
>
<div
class="rcx-box rcx-box--full rcx-css-w398ts"
>
<input
aria-controls="react-aria-_r_4_-listbox"
aria-describedby=""
aria-expanded="false"
aria-haspopup="listbox"
aria-invalid="false"
aria-labelledby="react-aria-_r_4_-label"
autocomplete="off"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-pqpp8q"
id="react-aria-_r_4_"
name="usernames"
placeholder="Add_people"
role="combobox"
value=""
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-1bprq55"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="react-aria-_r_5_-label"
>
Message
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<textarea
aria-describedby="react-aria-_r_5_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-textarea rcx-input-box"
cols="1"
id="react-aria-_r_5_-label"
name="firstMessage"
rows="5"
size="1"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_5_-hint"
>
First_message_hint
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-field-group__item"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<span
class="rcx-box rcx-box--full rcx-field__label rcx-label"
>
Encrypted
</span>
<label
class="rcx-box rcx-box--full rcx-toggle-switch"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
Encrypted
</div>
<input
aria-describedby="react-aria-_r_6_-hint"
aria-invalid="false"
class="rcx-box rcx-box--full rcx-toggle-switch__input"
id="react-aria-_r_6_-label"
name="encrypted"
type="checkbox"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
/>
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
id="react-aria-_r_6_-hint"
>
Not_available_for_this_workspace
</span>
</div>
</fieldset>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="submit"
>
<span
class="rcx-button--content"
>
Create
</span>
</button>
</div>
</div>
</form>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export { default } from './CreateDiscussion';

View File

@@ -0,0 +1,35 @@
import styled from '@rocket.chat/styled';
import { getEmojiClassNameAndDataTitle } from '../lib/utils/renderEmoji';
export type EmojiProps = {
emojiHandle: string; // :emoji:
className?: string;
fillContainer?: boolean;
};
const EmojiComponent = styled('span', ({ fillContainer: _fillContainer, ...props }: { fillContainer?: boolean }) => props)`
${({ fillContainer }) =>
fillContainer
? `
display: inline-block;
width: 100%;
height: 100%;
margin: 0;`
: ''}
`;
function Emoji({ emojiHandle, className = undefined, fillContainer }: EmojiProps) {
const { className: emojiClassName, image, ...props } = getEmojiClassNameAndDataTitle(emojiHandle);
return (
<EmojiComponent
className={[emojiClassName, className, image ? 'rcx-message__emoji--custom' : ''].filter(Boolean).join(' ')}
style={image?.length ? { backgroundImage: image } : undefined}
fillContainer={fillContainer}
{...props}
/>
);
}
export default Emoji;

View File

@@ -0,0 +1,43 @@
import { Box, Icon, TextInput, Margins } from '@rocket.chat/fuselage';
import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks';
import type { ChangeEvent, ComponentPropsWithoutRef, SubmitEvent } from 'react';
import { forwardRef, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
// TODO: consider changing the type of TextInput's `onChange` to (event: ChangeEvent<HTMLInputElement>) => void
export type FilterByTextProps = Omit<ComponentPropsWithoutRef<typeof TextInput>, 'onChange'> & {
shouldAutoFocus?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
const FilterByText = forwardRef<HTMLInputElement, FilterByTextProps>(function FilterByText(
{ placeholder, shouldAutoFocus = false, children, ...props },
ref,
) {
const { t } = useTranslation();
const autoFocusRef = useAutoFocus(shouldAutoFocus);
const mergedRefs = useMergedRefs(ref, autoFocusRef);
const handleFormSubmit = useCallback((event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
}, []);
return (
<Box marginBlock={16} marginInline='neg-x4' is='form' onSubmit={handleFormSubmit} display='flex' flexWrap='wrap' alignItems='center'>
<Box marginInline={4} display='flex' flexGrow={1}>
<TextInput
{...props}
placeholder={placeholder ?? t('Search')}
ref={mergedRefs}
endAddon={<Icon name='magnifier' size='x20' />}
flexGrow={2}
minWidth='x220'
aria-label={placeholder ?? t('Search')}
/>
</Box>
{children && <Margins inline={4}>{children}</Margins>}
</Box>
);
});
export default memo(FilterByText);

View File

@@ -0,0 +1,38 @@
import { Box } from '@rocket.chat/fuselage';
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import { links } from '../lib/links';
export type FingerprintChangeModalProps = {
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
};
const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={t('Unique_ID_change_detected')}
onConfirm={onConfirm}
onClose={onClose}
onCancel={onCancel}
confirmText={t('Configuration_update')}
cancelText={t('New_workspace')}
>
<Box is='p' marginBlockEnd={16}>
<Trans i18nKey='Unique_ID_change_detected_description' />
</Box>
<Box is='p' marginBlockEnd={16}>
<Trans
i18nKey='Unique_ID_change_detected_learn_more_link'
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
/>
</Box>
</GenericModal>
);
};
export default FingerprintChangeModal;

View File

@@ -0,0 +1,43 @@
import { Box } from '@rocket.chat/fuselage';
import { ExternalLink, GenericModal } from '@rocket.chat/ui-client';
import { Trans, useTranslation } from 'react-i18next';
import { links } from '../lib/links';
export type FingerprintChangeModalConfirmationProps = {
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
newWorkspace: boolean;
};
const FingerprintChangeModalConfirmation = ({ onConfirm, onCancel, onClose, newWorkspace }: FingerprintChangeModalConfirmationProps) => {
const { t } = useTranslation();
return (
<GenericModal
variant='warning'
title={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
onConfirm={onConfirm}
onCancel={onCancel}
cancelText={t('Back')}
confirmText={newWorkspace ? t('Confirm_new_workspace') : t('Confirm_configuration_update')}
onClose={onClose}
>
<Box is='p' marginBlockEnd={16}>
{newWorkspace ? (
<Trans i18nKey='Confirm_new_workspace_description' />
) : (
<Trans i18nKey='Confirm_configuration_update_description' />
)}
</Box>
<Box is='p' marginBlockEnd={16}>
<Trans
i18nKey='Unique_ID_change_detected_learn_more_link'
components={{ a: <ExternalLink to={links.go.fingerPrintChangedFaq} /> }}
/>
</Box>
</GenericModal>
);
};
export default FingerprintChangeModalConfirmation;

View File

@@ -0,0 +1,229 @@
import { Markup } from '@rocket.chat/gazzodown';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import GazzodownText from './GazzodownText';
import { useMessageListHighlights } from './message/list/MessageListContext';
jest.mock('@rocket.chat/ui-client', () => ({
useFeaturePreview: () => false,
}));
jest.mock('@rocket.chat/fuselage-hooks', () => ({
useLocalStorage: () => ['en', jest.fn()],
}));
jest.mock('./message/list/MessageListContext', () => ({
useMessageListHighlights: jest.fn(),
useMessageListShowRealName: jest.fn(() => false),
}));
jest.mock('../lib/utils/fireGlobalEvent', () => ({
fireGlobalEvent: jest.fn(),
}));
jest.mock('../views/room/hooks/useGoToRoom', () => ({
useGoToRoom: jest.fn(),
}));
const mockUseMessageListHighlights = useMessageListHighlights as jest.MockedFunction<typeof useMessageListHighlights>;
const wrapper = mockAppRoot().withUserPreference('useEmojis', true).withSetting('UI_Use_Real_Name', false).withJohnDoe();
const HighlightTester = ({ text }: { text: string }) => {
return (
<Markup
tokens={[
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: text }],
},
]}
/>
);
};
describe('GazzodownText highlights', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should highlight Russian word "тест" in the middle of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Это тест сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// Expect that the highlighted element wraps exactly "тест"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should highlight Russian word "тест" at the beginning of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Тест сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should not highlight "тест" when it is part of a larger word', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Тестирование сообщения' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.queryByTitle('Highlighted_chosen_word')).not.toBeInTheDocument();
});
it('should highlight Russian word "тест" at the end of a sentence', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'тест' }] as any);
render(
<GazzodownText>
<HighlightTester text='Сообщение тест' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^тест$/i);
});
it('should highlight English word "test" regardless of its position', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is a test message' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight all occurrences of the highlighted word in different positions', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='test is at the beginning, in the middle test, and at the end test' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
// Expect three separate highlights.
expect(highlightedElements.length).toBe(3);
highlightedElements.forEach((el) => {
expect(el).toHaveTextContent(/^test$/i);
});
});
it('should highlight the highlighted word in a multiline text', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
const multilineText = `First line
Test line
Another line with test
in it.`;
render(
<GazzodownText>
<HighlightTester text={multilineText} />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
// At least two occurrences are expected: one for "Test" (capitalized) and one for "test"
expect(highlightedElements.length).toBeGreaterThanOrEqual(2);
// Verify that the highlighted texts match "test" (case-insensitive)
highlightedElements.forEach((el) => {
expect(el.textContent).toMatch(/^test$/i);
});
});
it('should highlight highlighted word when surrounded by colons', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is :test: inside colons' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight highlighted word when colon is at the start', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is :test with colon at the start' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight highlighted word when colon is at the end', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is test: with colon at end' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
// The highlighted element should contain only "test"
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
it('should highlight multiple different highlighted words in the same text', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'test' }, { highlight: 'highlight' }] as any);
render(
<GazzodownText>
<HighlightTester text='This test message should highlight the word highlight in multiple places: test and highlight.' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
const highlightedElements = screen.getAllByTitle('Highlighted_chosen_word');
expect(highlightedElements.length).toBe(5);
const texts = highlightedElements.map((el) => el.textContent?.toLowerCase());
expect(texts).toEqual(expect.arrayContaining(['test', 'highlight']));
});
it('should highlight a word containing special characters like "-", "_", ".", "/", "=", "!", ":', () => {
// The highlight word includes special characters.
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'te-st_te.st/te=te!st:' }] as any);
const testText = 'This message contains te-st_te.st/te=te!st: as a highlighted word.';
render(
<GazzodownText>
<HighlightTester text={testText} />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^te-st_te\.st\/te=te!st:$/i);
});
it('should highlight the word in a case-insensitive manner', () => {
mockUseMessageListHighlights.mockReturnValue([{ highlight: 'Test' }] as any);
render(
<GazzodownText>
<HighlightTester text='This is a test message' />
</GazzodownText>,
{ wrapper: wrapper.build() },
);
expect(screen.getByTitle('Highlighted_chosen_word')).toHaveTextContent(/^test$/i);
});
});

View File

@@ -0,0 +1,144 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { ChannelMention, UserMention } from '@rocket.chat/gazzodown';
import { MarkupInteractionContext } from '@rocket.chat/gazzodown';
import { escapeRegExp } from '@rocket.chat/tools';
import { useLayout, useRouter, useUserPreference, useUserId, useUserCard } from '@rocket.chat/ui-contexts';
import type { UIEvent, ReactNode } from 'react';
import { useCallback, memo, useMemo } from 'react';
import { normalizeUsername } from '../../lib/utils/normalizeUsername';
import { detectEmoji } from '../lib/utils/detectEmoji';
import { fireGlobalEvent } from '../lib/utils/fireGlobalEvent';
import { useMessageListHighlights, useMessageListShowRealName } from './message/list/MessageListContext';
import { useGoToRoom } from '../views/room/hooks/useGoToRoom';
export type GazzodownTextProps = {
children: ReactNode;
mentions?: {
type?: 'user' | 'team';
_id: string;
username?: string;
name?: string;
}[];
channels?: Pick<IRoom, '_id' | 'name'>[];
searchText?: string;
};
const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTextProps) => {
const [userLanguage] = useLocalStorage('userLanguage', 'en');
const highlights = useMessageListHighlights();
const { triggerProps, openUserCard } = useUserCard();
const highlightRegex = useMemo(() => {
if (!highlights?.length) {
return;
}
// Due to unnecessary escaping in escapeRegExp, we need to remove the escape character for the following characters: - = ! :
// This is necessary because it was crashing the client due to Invalid regular expression error.
const alternatives = highlights.map(({ highlight }) => escapeRegExp(highlight).replace(/\\([-=!:])/g, '$1')).join('|');
const expression = `(?<=^|[\\p{P}\\p{Z}])(${alternatives})(?=$|[\\p{P}\\p{Z}])`;
return (): RegExp => new RegExp(expression, 'gmiu');
}, [highlights]);
const markRegex = useMemo(() => {
if (!searchText) {
return;
}
return (): RegExp => new RegExp(`(${searchText})(?![^<]*>)`, 'gi');
}, [searchText]);
const convertAsciiToEmoji = useUserPreference<boolean>('convertAsciiEmoji', true);
const useEmoji = useUserPreference<boolean>('useEmojis', true);
const useRealName = useMessageListShowRealName();
const ownUserId = useUserId();
const showMentionSymbol = Boolean(useUserPreference<boolean>('mentionsWithSymbol'));
const resolveUserMention = useCallback(
(mention: string) => {
if (mention === 'all' || mention === 'here') {
return undefined;
}
const filterUser = ({ username, type }: UserMention) => {
if (!username || type === 'team') return false;
return normalizeUsername(username) === normalizeUsername(mention);
};
const filterTeam = ({ name, type }: UserMention) => type === 'team' && name === mention;
return mentions?.find((mention) => filterUser(mention) || filterTeam(mention));
},
[mentions],
);
const onUserMentionClick = useCallback(
({ username }: UserMention) => {
if (!username) {
return;
}
return (event: UIEvent): void => {
event.stopPropagation();
openUserCard(event, username);
};
},
[openUserCard],
);
const goToRoom = useGoToRoom();
const { isEmbedded, isMobile } = useLayout();
const resolveChannelMention = useCallback((mention: string) => channels?.find(({ name }) => name === mention), [channels]);
const router = useRouter();
const onChannelMentionClick = useCallback(
({ _id: rid }: ChannelMention) =>
(event: UIEvent): void => {
if (isEmbedded) {
fireGlobalEvent('click-mention-link', {
path: router.buildRoutePath({
pattern: '/channel/:name/:tab?/:context?',
params: { name: rid },
}),
channel: rid,
});
}
event.stopPropagation();
goToRoom(rid);
},
[router, isEmbedded, goToRoom],
);
return (
<MarkupInteractionContext.Provider
value={{
detectEmoji,
highlightRegex,
markRegex,
resolveUserMention,
onUserMentionClick,
resolveChannelMention,
onChannelMentionClick,
convertAsciiToEmoji,
useEmoji,
useRealName,
isMobile,
ownUserId,
showMentionSymbol,
triggerProps,
language: userLanguage,
}}
>
{children}
</MarkupInteractionContext.Provider>
);
};
export default memo(GazzodownText);

View File

@@ -0,0 +1,33 @@
import { Card, CardTitle, CardBody, CardControls, CardHeader, FramedIcon } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import type { GenericCardButton } from './GenericCardButton';
export type GenericCardProps = {
title: string;
body: string;
buttons?: ReactElement<typeof GenericCardButton>[];
icon?: ComponentProps<typeof FramedIcon>['icon'];
type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
} & ComponentProps<typeof Card>;
export const GenericCard = ({ title, body, buttons, icon, type, ...props }: GenericCardProps) => {
const cardId = useId();
const descriptionId = useId();
const iconType = type && {
[type]: true,
};
return (
<Card role='region' aria-labelledby={cardId} aria-describedby={descriptionId} {...props}>
<CardHeader>
{icon && <FramedIcon icon={icon} {...(type && iconType)} />}
<CardTitle id={cardId}>{title}</CardTitle>
</CardHeader>
<CardBody id={descriptionId}>{body}</CardBody>
{buttons && <CardControls>{buttons}</CardControls>}
</Card>
);
};

View File

@@ -0,0 +1,4 @@
import { Button } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export const GenericCardButton = (props: ComponentProps<typeof Button>) => <Button {...props} medium />;

View File

@@ -0,0 +1,2 @@
export * from './GenericCard';
export * from './GenericCardButton';

View File

@@ -0,0 +1,30 @@
import { Box, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useTranslation } from 'react-i18next';
export type GenericErrorProps = {
icon?: IconName;
title?: string;
buttonTitle?: string;
buttonAction?: () => void;
};
const GenericError = ({ icon = 'magnifier', title, buttonTitle, buttonAction }: GenericErrorProps) => {
const { t } = useTranslation();
return (
<Box display='flex' height='100%' flexDirection='column' justifyContent='center'>
<States>
<StatesIcon name={icon} variation='danger' />
<StatesTitle>{title || t('Something_went_wrong')}</StatesTitle>
{buttonAction && (
<StatesActions>
<StatesAction onClick={buttonAction}>{buttonTitle || t('Reload_page')}</StatesAction>
</StatesActions>
)}
</States>
</Box>
);
};
export default GenericError;

View File

@@ -0,0 +1 @@
export { default } from './GenericError';

View File

@@ -0,0 +1,30 @@
import { TextInput, Box, Icon } from '@rocket.chat/fuselage';
import type { Meta, StoryFn } from '@storybook/react';
import GenericNoResults from './GenericNoResults';
export default {
component: GenericNoResults,
parameters: {
layout: 'padded',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
(fn) => <div style={{ height: '100vh', maxHeight: 300, display: 'flex', flexDirection: 'column', marginInline: 24 }}>{fn()}</div>,
],
} satisfies Meta<typeof GenericNoResults>;
const filter = (
<>
<Box marginBlock={16} is='form' display='flex' flexDirection='column'>
<TextInput flexShrink={0} placeholder='Search...' endAddon={<Icon name='magnifier' size='x20' />} />
</Box>
</>
);
export const NoResults: StoryFn<typeof GenericNoResults> = () => (
<>
{filter}
<GenericNoResults />
</>
);

View File

@@ -0,0 +1,47 @@
import { Box, States, StatesIcon, StatesLink, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useTranslation } from 'react-i18next';
type LinkProps = { linkText: string; linkHref: string } | { linkText?: never; linkHref?: never };
type ButtonProps = { buttonTitle: string; buttonAction: () => void } | { buttonTitle?: never; buttonAction?: never };
export type GenericNoResultsProps = {
icon?: IconName | null;
title?: string;
description?: string;
} & LinkProps &
ButtonProps;
const GenericNoResults = ({
icon = 'magnifier',
title,
description,
buttonTitle,
buttonAction,
linkHref,
linkText,
}: GenericNoResultsProps) => {
const { t } = useTranslation();
return (
<Box role='group' aria-label={t('No_results_found')} display='flex' height='100%' flexDirection='column' justifyContent='center'>
<States>
{icon && <StatesIcon name={icon} />}
<StatesTitle>{title || t('No_results_found')}</StatesTitle>
{description && <StatesSubtitle>{description}</StatesSubtitle>}
{buttonTitle && buttonAction && (
<StatesActions>
<StatesAction onClick={buttonAction}>{buttonTitle}</StatesAction>
</StatesActions>
)}
{linkText && linkHref && (
<StatesLink href={linkHref} target='_blank'>
{linkText}
</StatesLink>
)}
</States>
</Box>
);
};
export default GenericNoResults;

View File

@@ -0,0 +1 @@
export { default } from './GenericNoResults';

View File

@@ -0,0 +1,61 @@
import { Box, ProgressBar } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ReactNode } from 'react';
const GenericResourceUsage = ({
title,
value,
max,
percentage,
threshold = 80,
variant = percentage < threshold ? 'success' : 'danger',
subTitle,
tooltip,
...props
}: {
title: string;
subTitle?: ReactNode;
value: number;
max: number;
percentage: number;
threshold?: number;
variant?: 'warning' | 'danger' | 'success';
tooltip?: string;
}) => {
const labelId = useId();
return (
<Box
title={tooltip}
width='x180'
height='x40'
marginInline={8}
fontScale='c1'
display='flex'
flexDirection='column'
justifyContent='space-around'
{...props}
>
<Box display='flex' justifyContent='space-between'>
<Box color='default' id={labelId}>
{title}
</Box>
{subTitle && <Box color='hint'>{subTitle}</Box>}
<Box color='hint'>
{value}/{max}
</Box>
</Box>
<ProgressBar
percentage={percentage}
variant={variant}
role='progressbar'
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
/>
</Box>
);
};
export default GenericResourceUsage;

View File

@@ -0,0 +1,26 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export type GenericResourceUsageSkeletonProps = {
title?: string;
} & ComponentProps<typeof Box>;
const GenericResourceUsageSkeleton = ({ title, ...props }: GenericResourceUsageSkeletonProps) => {
return (
<Box
width='x180'
height='x40'
marginInline={8}
fontScale='c1'
display='flex'
flexDirection='column'
justifyContent='space-around'
{...props}
>
{title ? <Box color='default'>{title}</Box> : <Skeleton width='full' />}
<Skeleton width='full' />
</Box>
);
};
export default GenericResourceUsageSkeleton;

View File

@@ -0,0 +1,4 @@
import GenericResourceUsage from './GenericResourceUsage';
import GenericResourceUsageSkeleton from './GenericResourceUsageSkeleton';
export { GenericResourceUsage, GenericResourceUsageSkeleton };

View File

@@ -0,0 +1,89 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import GenericUpsellModal from './GenericUpsellModal';
import * as stories from './GenericUpsellModal.stories';
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Premium_capability: 'Premium capability',
Cancel: 'Cancel',
Upgrade: 'Upgrade',
})
.build();
describe('GenericUpsellModal', () => {
const defaultProps = {
title: 'Test Title',
img: 'test-image.png',
onClose: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
});
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render basic properties', () => {
const props = {
...defaultProps,
subtitle: 'Test Subtitle',
description: 'Test Description',
onCancel: jest.fn(),
onConfirm: jest.fn(),
};
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Subtitle')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
});
it('should render with default confirm and cancel buttons', () => {
render(<GenericUpsellModal {...defaultProps} onCancel={jest.fn()} onConfirm={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument();
});
it('should render with default tagline', () => {
render(<GenericUpsellModal {...defaultProps} />, { wrapper: appRoot });
expect(screen.getByText('Premium capability')).toBeInTheDocument();
});
it('should render with img properties', () => {
const props = {
...defaultProps,
onCancel: jest.fn(),
onConfirm: jest.fn(),
imgWidth: 300,
imgHeight: 200,
imgAlt: 'Alternative text',
};
render(<GenericUpsellModal {...props} />, { wrapper: appRoot });
const heroImage = screen.getByRole('img');
expect(heroImage).toHaveAttribute('src', 'test-image.png');
expect(heroImage).toHaveAttribute('alt', 'Alternative text');
expect(heroImage).toHaveStyle({
width: '300px',
height: '200px',
});
});
});

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import GenericUpsellModal from '.';
const meta = {
component: GenericUpsellModal,
args: {
onClose: action('onClose'),
img: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z',
imgAlt: 'Placeholder image',
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof GenericUpsellModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: 'This is the title',
subtitle: 'This is a subtitle',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
imgAlt: 'Placeholder image',
onCancel: action('onCancel'),
onConfirm: action('onConfirm'),
},
};
export const WithCustomTagLine: Story = {
args: {
...Default.args,
tagline: 'Exclusive',
},
};
export const WithCustomButtons: Story = {
args: {
...Default.args,
cancelText: 'Learn More',
confirmText: 'Contact Sales',
},
};
export const WithAnnotation: Story = {
args: {
...Default.args,
annotation: 'This is an annotation.',
},
};
export const WithFixedWidthAndHeight: Story = {
args: {
...Default.args,
imgHeight: 600,
imgWidth: 600,
},
};

View File

@@ -0,0 +1,54 @@
import { Box, ModalHeroImage } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import type { ComponentProps, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type GenericUpsellModalProps = Omit<ComponentProps<typeof GenericModal>, 'variant' | 'children' | 'onClose' | 'onDismiss'> & {
subtitle?: ReactNode;
description?: ReactNode;
img: ComponentProps<typeof ModalHeroImage>['src'];
imgWidth?: ComponentProps<typeof ModalHeroImage>['width'];
imgHeight?: ComponentProps<typeof ModalHeroImage>['height'];
imgAlt?: string;
onClose: () => void;
onConfirm?: () => void;
};
const GenericUpsellModal = ({
tagline,
subtitle,
img,
imgAlt = '',
imgWidth,
imgHeight,
description,
confirmText,
icon = null,
...props
}: GenericUpsellModalProps) => {
const { t } = useTranslation();
return (
<GenericModal
{...props}
icon={icon}
tagline={tagline ?? t('Premium_capability')}
variant='upsell'
confirmText={confirmText ?? t('Upgrade')}
>
<ModalHeroImage src={img} alt={imgAlt} width={imgWidth} height={imgHeight} />
{subtitle && (
<Box is='h3' fontScale='h3'>
{subtitle}
</Box>
)}
{description && (
<Box style={{ whiteSpace: 'break-spaces' }} fontScale='p2' marginBlockStart={16}>
{description}
</Box>
)}
</GenericModal>
);
};
export default GenericUpsellModal;

View File

@@ -0,0 +1,561 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`GenericUpsellModal renders Default without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithAnnotation without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_1_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_1_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-rzpqmp"
>
<div
class="rcx-box rcx-box--full rcx-modal__footer-annotation"
>
This is an annotation.
</div>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithCustomButtons without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_2_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_2_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Learn More
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Contact Sales
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithCustomTagLine without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_3_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Exclusive
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_3_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;
exports[`GenericUpsellModal renders WithFixedWidthAndHeight without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_4_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<div
class="rcx-box rcx-box--full rcx-modal__tagline rcx-css-1ozohik"
>
Premium capability
</div>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_4_-title"
>
This is the title
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
<figure
class="rcx-modal__hero-image-wrapper"
>
<img
alt="Placeholder image"
class="rcx-box rcx-box--full rcx-modal__hero-image rcx-css-1k43lja"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
<h3
class="rcx-box rcx-box--full rcx-css-1sfto34"
>
This is a subtitle
</h3>
<div
class="rcx-box rcx-box--full rcx-css-1s3t8yc"
style="white-space: break-spaces;"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816"
>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Upgrade
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export * from './useUpsellActions';

View File

@@ -0,0 +1,67 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { UseQueryResult } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useUpsellActions } from './useUpsellActions';
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
jest.mock('../../../hooks/useIsEnterprise', () => ({
useIsEnterprise: jest.fn(),
}));
const appRoot = mockAppRoot()
.withJohnDoe()
.withEndpoint('GET', '/v1/licenses.info', async () => ({ license: createFakeLicenseInfo({ hasValidLicense: true }) }))
.build();
describe('useUpsellActions hook', () => {
beforeEach(() => {
(useIsEnterprise as jest.Mock).mockImplementation(jest.requireActual('../../../hooks/useIsEnterprise').useIsEnterprise);
});
afterAll(() => {
jest.clearAllMocks();
});
it('should show upsell modal if not enterprise', () => {
const { result } = renderHook(() => useUpsellActions(), {
wrapper: mockAppRoot().build(),
});
expect(result.current.shouldShowUpsell).toBe(true);
expect(result.current.cloudWorkspaceHadTrial).toBe(false);
});
it('should show upsell modal if enterprise but has no license module', async () => {
const { result } = renderHook(() => useUpsellActions(false), {
wrapper: appRoot,
});
await waitFor(() => {
expect(result.current.shouldShowUpsell).toBe(true);
});
});
it('should NOT show upsell if enterprise and has license module', async () => {
const { result } = renderHook(() => useUpsellActions(true), {
wrapper: appRoot,
});
await waitFor(() => {
expect(result.current.shouldShowUpsell).toBe(false);
});
});
it('should show upsell if useIsEnterprise is undefined', async () => {
(useIsEnterprise as jest.Mock).mockReturnValue({
data: undefined,
} as UseQueryResult);
const { result } = renderHook(() => useUpsellActions(true), {
wrapper: mockAppRoot().build(),
});
expect(result.current.shouldShowUpsell).toBe(true);
});
});

View File

@@ -0,0 +1,33 @@
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
import { links } from '../../../lib/links';
import { useCheckoutUrl } from '../../../views/admin/subscription/hooks/useCheckoutUrl';
const TALK_TO_SALES_URL = links.go.contactSales;
export const useUpsellActions = (hasLicenseModule = false) => {
const setModal = useSetModal();
const handleOpenLink = useExternalLink();
const cloudWorkspaceHadTrial = useSetting('Cloud_Workspace_Had_Trial', false);
const { data } = useIsEnterprise();
const shouldShowUpsell = !data?.isEnterprise || !hasLicenseModule;
const openExternalLink = useExternalLink();
const manageSubscriptionUrl = useCheckoutUrl()({ target: 'upsell-modal', action: 'upgrade' });
const handleManageSubscription = useCallback(() => {
openExternalLink(manageSubscriptionUrl);
setModal(null);
}, [manageSubscriptionUrl, openExternalLink, setModal]);
const handleTalkToSales = useCallback(() => {
handleOpenLink(TALK_TO_SALES_URL);
setModal(null);
}, [handleOpenLink, setModal]);
return { shouldShowUpsell, cloudWorkspaceHadTrial, handleManageSubscription, handleTalkToSales };
};

View File

@@ -0,0 +1 @@
export { default } from './GenericUpsellModal';

View File

@@ -0,0 +1,230 @@
import { FocusScope } from '@react-aria/focus';
import type { IUpload } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, ButtonGroup, IconButton, Palette, PaletteStyleTag, Throbber, spacing } from '@rocket.chat/fuselage';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Navigation, Zoom, Keyboard, A11y } from 'swiper/modules/index.mjs';
import type { SwiperClass, SwiperRef } from 'swiper/swiper-react';
import { Swiper, SwiperSlide } from 'swiper/swiper-react.mjs';
import 'swiper/swiper.css';
import 'swiper/modules/zoom.css';
import { usePreventPropagation } from '../../hooks/usePreventPropagation';
const swiperStyle = css`
.swiper {
width: 100%;
height: 100%;
}
.swiper-container {
position: absolute;
z-index: 99;
top: 0;
overflow: hidden;
width: 100%;
height: 100%;
background-color: var(--rcx-color-surface-overlay, rgba(0, 0, 0, 0.6));
}
.swiper-slide {
padding: ${spacing('x144')} ${spacing('x60')} ${spacing('x96')};
}
.rcx-swiper-prev-button,
.rcx-swiper-next-button {
position: absolute;
z-index: 10;
top: 50%;
cursor: pointer;
}
.rcx-swiper-prev-button.swiper-button-disabled,
.rcx-swiper-next-button.swiper-button-disabled {
cursor: auto;
pointer-events: none;
opacity: 0.35;
}
.rcx-swiper-prev-button.swiper-button-hidden,
.rcx-swiper-next-button.swiper-button-hidden {
cursor: auto;
pointer-events: none;
opacity: 0;
}
.rcx-swiper-prev-button,
.swiper-rtl .rcx-swiper-next-button {
right: auto;
left: 10px;
}
.rcx-swiper-next-button,
.swiper-rtl .rcx-swiper-prev-button {
right: 10px;
left: auto;
}
.rcx-lazy-preloader {
position: absolute;
z-index: -1;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: ${Palette.text['font-pure-white']};
}
.rcx-swiper-controls {
position: absolute;
top: 0;
right: 0;
padding: 10px;
z-index: 2;
width: 100%;
display: flex;
justify-content: flex-end;
background-color: ${Palette.surface['surface-sidebar']};
}
`;
export const ImageGallery = ({
images,
onClose,
loadMore,
}: {
images: Pick<IUpload, '_id' | 'path' | 'url' | 'description'>[];
onClose: () => void;
loadMore?: () => void;
}) => {
const { t } = useTranslation();
const swiperRef = useRef<SwiperRef>(null);
const [, setSwiperInst] = useState<SwiperClass>();
const [zoomScale, setZoomScale] = useState(1);
const [gridSize, setGridSize] = useState(images.length);
const handleZoom = (ratio: number) => {
if (swiperRef.current?.swiper.zoom) {
const { scale, in: zoomIn } = swiperRef.current?.swiper.zoom;
setZoomScale(scale + ratio);
return zoomIn(scale + ratio);
}
};
const handleZoomIn = () => handleZoom(1);
const handleZoomOut = () => handleZoom(-1);
const handleResize = () => handleZoom(-(zoomScale - 1));
const preventPropagation = usePreventPropagation();
return createPortal(
<>
<PaletteStyleTag theme='dark' selector='.swiper-container.image-gallery' tagId='image-gallery-palette' />
<FocusScope contain autoFocus>
<Box role='dialog' aria-modal='true' aria-label={t('Image_gallery')} className={swiperStyle}>
<div role='presentation' className='swiper-container image-gallery' onClick={onClose}>
<ButtonGroup role='toolbar' className='rcx-swiper-controls' onClick={preventPropagation}>
{zoomScale !== 1 && (
<IconButton
name='resize'
small
icon='arrow-collapse'
title={t('Resize')}
rcx-swiper-zoom-out
secondary
onClick={handleResize}
/>
)}
<IconButton
name='zoom-out'
small
icon='h-bar'
title={t('Zoom_out')}
rcx-swiper-zoom-out
onClick={handleZoomOut}
secondary
disabled={zoomScale === 1}
/>
<IconButton name='zoom-in' small icon='plus' title={t('Zoom_in')} rcx-swiper-zoom-in secondary onClick={handleZoomIn} />
<IconButton
name='close'
small
icon='cross'
aria-label={t('Close_gallery')}
className='rcx-swiper-close-button'
secondary
onClick={onClose}
/>
</ButtonGroup>
<IconButton
icon='chevron-right'
aria-label={t('Next_image')}
className='rcx-swiper-prev-button'
secondary
onClick={preventPropagation}
/>
<IconButton
icon='chevron-left'
aria-label={t('Previous_image')}
className='rcx-swiper-next-button'
secondary
onClick={preventPropagation}
/>
<Swiper
ref={swiperRef}
navigation={{
nextEl: '.rcx-swiper-next-button',
prevEl: '.rcx-swiper-prev-button',
}}
keyboard
zoom={{ toggle: false }}
lazyPreloaderClass='rcx-lazy-preloader'
runCallbacksOnInit
onKeyPress={(_: SwiperClass, keyCode: string) => String(keyCode) === '27' && onClose()}
modules={[Navigation, Zoom, Keyboard, A11y]}
onInit={(swiper: SwiperClass) => setSwiperInst(swiper)}
onSlidesGridLengthChange={(swiper: SwiperClass) => {
swiper.slideTo(images.length - gridSize, 0);
setGridSize(images.length);
}}
onReachBeginning={loadMore}
initialSlide={images.length - 1}
>
{[...images].reverse().map(({ _id, path, url, description }) => (
<SwiperSlide key={_id}>
<div className='swiper-zoom-container'>
{/* eslint-disable-next-line
jsx-a11y/no-noninteractive-element-interactions,
jsx-a11y/click-events-have-key-events
*/}
<img
src={path || url}
loading='lazy'
alt={description || ''}
data-qa-zoom-scale={zoomScale}
onClick={preventPropagation}
/>
<div className='rcx-lazy-preloader'>
<Throbber inheritColor />
</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</Box>
</FocusScope>
</>,
document.body,
);
};

View File

@@ -0,0 +1,25 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton, ModalBackdrop } from '@rocket.chat/fuselage';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import GenericError from '../GenericError/GenericError';
const closeButtonStyle = css`
position: absolute;
z-index: 10;
top: 10px;
right: 10px;
`;
export const ImageGalleryError = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
return createPortal(
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
<GenericError buttonAction={onClose} buttonTitle={t('Close')} />
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
</ModalBackdrop>,
document.body,
);
};

View File

@@ -0,0 +1,23 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton, ModalBackdrop, Throbber } from '@rocket.chat/fuselage';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
const closeButtonStyle = css`
position: absolute;
z-index: 10;
top: 10px;
right: 10px;
`;
export const ImageGalleryLoading = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
return createPortal(
<ModalBackdrop display='flex' justifyContent='center' color='pure-white'>
<IconButton icon='cross' aria-label={t('Close_gallery')} className={closeButtonStyle} onClick={onClose} />
<Throbber inheritColor />
</ModalBackdrop>,
document.body,
);
};

View File

@@ -0,0 +1,3 @@
export * from './ImageGallery';
export * from './ImageGalleryError';
export * from './ImageGalleryLoading';

View File

@@ -0,0 +1,39 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useEffect, useRef } from 'react';
export type InfiniteListAnchorProps = {
loadMore: () => void;
} & ComponentProps<typeof Box>;
const InfiniteListAnchor = ({ loadMore, ...props }: InfiniteListAnchorProps) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const target = ref.current;
if (!target) {
return;
}
const observer = new IntersectionObserver(
(e) => {
if (e[0].isIntersecting) {
loadMore();
}
},
{
root: null,
threshold: 0.1,
},
);
observer.observe(target);
return () => observer.disconnect();
}, [loadMore]);
return <Box width={5} height={5} ref={ref} {...props} />;
};
export default InfiniteListAnchor;

View File

@@ -0,0 +1,24 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './RetentionPolicyCallout.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
jest.useFakeTimers();
jest.setSystemTime(new Date(2024, 5, 1, 0, 0, 0));
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
// We have to use real timers here because `jest-axe` is breaking otherwise
jest.useRealTimers();
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryFn } from '@storybook/react';
import RetentionPolicyCallout from './RetentionPolicyCallout';
import { createRenteionPolicySettingsMock as createMock } from '../../../tests/mocks/client/mockRetentionPolicySettings';
import { createFakeRoom } from '../../../tests/mocks/data';
export default {
component: RetentionPolicyCallout,
} satisfies Meta<typeof RetentionPolicyCallout>;
const fakeRoom = createFakeRoom();
const DefaultWrapper = createMock({ appliesToChannels: true, TTLChannels: 60000 });
export const Default: StoryFn<typeof RetentionPolicyCallout> = () => (
<DefaultWrapper>
<RetentionPolicyCallout room={fakeRoom} />
</DefaultWrapper>
);
const InvalidSettingsWrapper = createMock({
appliesToChannels: true,
TTLChannels: 60000,
advancedPrecisionCron: '* * * 12 * *',
advancedPrecision: true,
});
export const InvalidSettings: StoryFn<typeof RetentionPolicyCallout> = () => (
<InvalidSettingsWrapper>
<RetentionPolicyCallout room={fakeRoom} />
</InvalidSettingsWrapper>
);

View File

@@ -0,0 +1,23 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { usePruneWarningMessage } from '../../hooks/usePruneWarningMessage';
import { withErrorBoundary } from '../withErrorBoundary';
export type RetentionPolicyCalloutProps = { room: IRoom };
const RetentionPolicyCallout = ({ room }: RetentionPolicyCalloutProps) => {
const message = usePruneWarningMessage(room);
const { t } = useTranslation();
return (
<Callout arial-label={t('Retention_policy_warning_callout')} role='alert' aria-live='polite' type='warning'>
<div>
<p>{message}</p>
</div>
</Callout>
);
};
export default withErrorBoundary(RetentionPolicyCallout);

View File

@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<section
aria-live="polite"
arial-label="Retention_policy_warning_callout"
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
role="alert"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper"
>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
>
<div
class="rcx-box rcx-box--full rcx-callout__content"
>
<div>
<p>
1 minute June 1, 2024 at 12:30 AM
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
`;
exports[`renders InvalidSettings without crashing 1`] = `
<body>
<div>
<section
aria-live="polite"
arial-label="Retention_policy_warning_callout"
class="rcx-box rcx-box--full rcx-callout rcx-callout--warning"
role="alert"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-warning rcx-icon rcx-callout__icon rcx-css-4pvxx3"
>
</i>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper"
>
<div
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
>
<div
class="rcx-box rcx-box--full rcx-callout__content"
>
<div>
<p>
1 minute June 12, 2024 at 12:00 AM
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
`;

View File

@@ -0,0 +1,18 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './InvitationBadge.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,32 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta } from '@storybook/react';
import InvitationBadge from './InvitationBadge';
const meta = {
component: InvitationBadge,
parameters: {
layout: 'centered',
},
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
Invited__date__: 'Invited {{date}}',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof InvitationBadge>;
export default meta;
export const WithISOStringDate = {
args: {
invitationDate: '2025-01-01T12:00:00Z',
},
};
export const WithDateObject = {
args: {
invitationDate: new Date('2025-01-01T12:00:00Z'),
},
};

View File

@@ -0,0 +1,28 @@
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { useTimeAgo } from '../../hooks/useTimeAgo';
export type InvitationBadgeProps = Omit<ComponentProps<typeof Icon>, 'name' | 'color' | 'role'> & {
invitationDate: string | Date;
};
const InvitationBadge = ({ invitationDate, ...props }: InvitationBadgeProps) => {
const { t } = useTranslation();
const timeAgo = useTimeAgo();
return (
<Icon
size='x20'
{...props}
role='status'
color='info'
name='mail'
aria-hidden='false'
title={t('Invited__date__', { date: timeAgo(invitationDate) })}
/>
);
};
export default InvitationBadge;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders WithDateObject without crashing 1`] = `
<body>
<div>
<i
aria-hidden="false"
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
role="status"
title="Invited January 1st, 2025"
>
</i>
</div>
</body>
`;
exports[`renders WithISOStringDate without crashing 1`] = `
<body>
<div>
<i
aria-hidden="false"
class="rcx-box rcx-box--full rcx-icon--name-mail rcx-icon rcx-css-oq4j9d"
role="status"
title="Invited January 1st, 2025"
>
</i>
</div>
</body>
`;

View File

@@ -0,0 +1 @@
export { default } from './InvitationBadge';

View File

@@ -0,0 +1,92 @@
import { Tile, OptionTitle, Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta, StoryFn } from '@storybook/react';
import ListItem from './Sidebar/ListItem';
export default {
component: Tile,
parameters: {
docs: {
description: {
component: '`ListItem` can be used on DropDown lists, to render an item with icon and text.',
},
},
},
} satisfies Meta<typeof Tile>;
export const ListWithIcon: StoryFn<typeof Tile> = () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='info' />
<ListItem text='Item 2' icon='star' />
<ListItem text='Item 3' icon='hashtag' />
<ListItem text='Item 4' icon='team' />
</Box>
</Tile>
);
};
export const NoIcon: StoryFn<typeof Tile> = () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' />
<ListItem text='Item 2' />
<ListItem text='Item 3' />
<ListItem text='Item 4' />
</Box>
</Tile>
);
};
export const MixedWithGap: StoryObj<typeof Tile> = {
render: () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='hashtag' />
<ListItem text='Item 2' icon='team' />
<ListItem text='Item 3' gap />
<ListItem text='Item 4' icon='airplane' />
</Box>
</Tile>
);
},
parameters: {
docs: {
description: {
story:
" When using `ListItem`, you can also use the `gap` prop to add spacing to the left. If the list is mixed with items **with and without** icons, it's recommended to add the gap.",
},
},
},
};
export const MixedWithoutGap: StoryObj<typeof Tile> = {
render: () => {
return (
<Tile elevation='2' padding='0' display='flex' flexDirection='column' overflow='auto' width='x240'>
<Box flexShrink={1} paddingBlock={12}>
<OptionTitle>Title</OptionTitle>
<ListItem text='Item 1' icon='hashtag' />
<ListItem text='Item 2' icon='team' />
<ListItem text='Item 3' />
<ListItem text='Item 4' icon='airplane' />
</Box>
</Tile>
);
},
parameters: {
docs: {
description: {
story: 'Not recommended. Prefer adding the `gap` prop to the items without icons.',
},
},
},
};

View File

@@ -0,0 +1,33 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { memo, useMemo } from 'react';
const availablePercentualWidths = [47, 68, 75, 82];
export type ListSkeletonProps = {
listCount?: number;
};
const ListSkeleton = ({ listCount = 2 }: ListSkeletonProps) => {
const widths = useMemo(
() => Array.from({ length: listCount }, (_, index) => `${availablePercentualWidths[index % availablePercentualWidths.length]}%`),
[listCount],
);
return (
<Box display='flex' height='100%' justifyContent='flex-start' flexDirection='column'>
{widths.map((width, index) => (
<Box key={index} paddingInline={24} paddingBlock={16} display='flex'>
<Box>
<Skeleton variant='rect' width={36} height={36} />
</Box>
<Box marginInlineStart={8} flexGrow={1}>
<Skeleton width='100%' />
<Skeleton width={width} />
</Box>
</Box>
))}
</Box>
);
};
export default memo(ListSkeleton);

View File

@@ -0,0 +1,13 @@
export type LoadingIndicatorProps = { variation?: 'medium' | 'large' };
const LoadingIndicator = ({ variation }: LoadingIndicatorProps) => {
return (
<div className='loading__animation'>
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
<div className={['loading__animation__bounce', variation && `loading__animation__bounce--${variation}`].filter(Boolean).join(' ')} />
</div>
);
};
export default LoadingIndicator;

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUTCClock } from '../hooks/useUTCClock';
export type LocalTimeProps = {
utcOffset: number;
};
const LocalTime = ({ utcOffset }: LocalTimeProps) => {
const time = useUTCClock(utcOffset);
const { t } = useTranslation();
return <>{t('Local_Time_time', { time })}</>;
};
export default memo(LocalTime);

View File

@@ -0,0 +1,614 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import dompurify from 'dompurify';
import MarkdownText, { supportedURISchemes } from './MarkdownText';
import '@testing-library/jest-dom';
import { emoji } from '../lib/emoji/lib';
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
jest.mock('../lib/getMarkdownParserLimit');
const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit);
const MOCKED_BASE_URI = 'http://localhost/';
// Mock getBaseURI from @rocket.chat/ui-client to ensure consistent behavior in tests
// This will affect the isExternal function imported above.
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'), // Import and retain default behavior for other exports
getBaseURI: jest.fn(() => MOCKED_BASE_URI), // Mock getBaseURI for consistent test behavior
}));
// List of common URI schemes. This list was taken from https://en.wikipedia.org/wiki/List_of_URI_schemes
const commonUriSchemes = [
'file',
'ftp',
'http',
'https',
'mailto',
'tel',
'imap',
'irc',
'nntp',
'acap',
'icap',
'mtqp',
'wss',
'admin',
'app',
'freeplane',
'geo',
'javascript',
'jdbc',
'msteams',
'ms-access',
'ms-excel',
'ms-infopath',
'ms-powerpoint',
'ms-project',
'ms-publisher',
'ms-spd',
'ms-visio',
'ms-word',
'odbc',
'psns',
'rdar',
's3',
'shortcuts',
'slack',
'stratum',
'trueconf',
'viber',
'zoommtgzoomus',
];
const nonSupportedUriSchemes = commonUriSchemes.filter((scheme) => !supportedURISchemes.includes(scheme));
const testUris = [
'example.com',
'example.com/path',
'example.com/path/to/resource',
'localhost/home',
'localhost/path/to/',
'localhost/path/to/resource',
];
const getTestCases = (schemes: string[], uris: string[]): { scheme: string; links: string[] }[] => {
return schemes.map((scheme) => {
return {
scheme,
links: uris.map((uri) => `${scheme}://${uri}`),
};
});
};
const isExternal = (link: string): boolean => link.indexOf(MOCKED_BASE_URI) !== 0;
const normalizeHtml = (html: any) => {
return html.replace(/\s+/g, ' ').trim();
};
const markdownText = `
# Heading 1
**Paragraph text**: *Bold with one asterisk* **Bold with two asterisks** Lorem ipsum dolor sit amet, consectetur adipiscing elit.
## Heading 2
_Italic Text_: _Italic with one underscore_ __Italic with two underscores__ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
### Heading 3
Lists, Links and elements
**Unordered List**
- List Item 1
- List Item 2
- List Item 3
- List Item 4
**Ordered List**
1. List Item 1
2. List Item 2
3. List Item 3
4. List Item 4
**Links:**
[Rocket.Chat](rocket.chat)
gabriel.engel@rocket.chat
+55991999999
\`Inline code\`
\`\`\`typescript
const test = 'this is code'
\`\`\`
**Bold text within __Italics__**
*Bold text with single asterik and underscore within _Italics_*
__Italics within **Bold** text__
_Italics within *Bold* text with single underscore and asterik_
`;
it('should render html elements as expected using default parser', async () => {
const { container } = render(<MarkdownText content={markdownText} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const normalizedHtml = normalizeHtml(container.innerHTML);
expect(normalizedHtml).toContain('<h1>Heading 1</h1>');
expect(normalizedHtml).toContain(
'<strong>Paragraph text</strong>: <strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('<h2>Heading 2</h2>');
expect(normalizedHtml).toContain(
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('<h3>Heading 3</h3>');
expect(normalizedHtml).toContain('<ul> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
expect(normalizedHtml).toContain('<ol> <li>List Item 1</li><li>List Item 2</li><li>List Item 3</li><li>List Item 4');
expect(normalizedHtml).toContain('<a');
expect(normalizedHtml).toContain('title=""');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('gabriel.engel@rocket.chat');
expect(normalizedHtml).toContain('+55991999999');
expect(normalizedHtml).toContain('<code>Inline code</code>');
expect(normalizedHtml).toContain('<pre><code class="language-typescript">const test = \'this is code\' </code></pre>');
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
});
it('should render html elements as expected using inline parser', async () => {
const { container } = render(<MarkdownText content={markdownText} variant='inline' />, {
wrapper: mockAppRoot().build(),
});
const normalizedHtml = normalizeHtml(container.innerHTML);
expect(normalizedHtml).toContain('# Heading 1');
expect(normalizedHtml).toContain(
'<strong>Bold with one asterisk</strong> <strong>Bold with two asterisks</strong> Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
);
expect(normalizedHtml).toContain('## Heading 2');
expect(normalizedHtml).toContain(
'<em>Italic Text</em>: <em>Italic with one underscore</em> <em>Italic with two underscores</em> Lorem ipsum dolor sit amet',
);
expect(normalizedHtml).toContain('### Heading 3');
expect(normalizedHtml).toContain('<strong>Unordered List</strong> - List Item 1 - List Item 2 - List Item 3 - List Item 4');
expect(normalizedHtml).toContain('<strong>Ordered List</strong> 1. List Item 1 2. List Item 2 3. List Item 3 4. List Item 4');
expect(normalizedHtml).toContain('<a');
expect(normalizedHtml).toContain('title=""');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('>Rocket.Chat</a>');
expect(normalizedHtml).toContain('href="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('title="mailto:gabriel.engel@rocket.chat"');
expect(normalizedHtml).toContain('rel="nofollow noopener noreferrer"');
expect(normalizedHtml).toContain('target="_blank"');
expect(normalizedHtml).toContain('+55991999999');
expect(normalizedHtml).toContain('Inline code');
expect(normalizedHtml).toContain(`typescript const test = 'this is code'`);
expect(normalizedHtml).toContain('<strong>Bold text within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<strong>Bold text with single asterik and underscore within <em>Italics</em></strong>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text</em>');
expect(normalizedHtml).toContain('<em>Italics within <strong>Bold</strong> text with single underscore and asterik</em>');
});
describe('links handling', () => {
it.each([
{
caseName: 'transform external http',
link: 'http://example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'http://example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform external https',
link: 'https://example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'https://example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform notes',
link: 'notes://example.com/path',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'notes://example.com/path',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform ftp',
link: 'ftp://example.com/file.txt',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'ftp://example.com/file.txt',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform ftps',
link: 'ftps://example.com/file.txt',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'ftps://example.com/file.txt',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform tel',
link: 'tel:+1234567890',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'tel:+1234567890',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform mailto',
link: 'mailto:test@example.com',
query: () => screen.getByRole('link', { name: 'mailto:test@example.com' }),
expectedHref: 'mailto:test@example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: 'mailto:test@example.com',
},
{
caseName: 'transform sms',
link: 'sms:+1234567890?body=hello',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'sms:+1234567890?body=hello',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform cid',
link: 'cid:someimage@example.com',
query: () => screen.getByRole('link', { name: 'Test Link' }),
expectedHref: 'cid:someimage@example.com',
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'filter relative',
link: '/channel/general',
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'transform absolute',
link: 'http://localhost/another-channel',
query: () => screen.getByRole('link', { name: 'Go to: another-channel' }),
expectedHref: 'http://localhost/another-channel',
expectedRel: undefined,
expectedTarget: undefined,
expectedTitleAttribute: 'Go to: another-channel',
},
{
caseName: 'filter unknown scheme',
link: 'invalid://example.com',
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
{
caseName: 'filter javascript',
link: "javascript:alert('XSS')",
query: () => screen.getByText('Test Link'),
expectedHref: undefined,
expectedRel: 'nofollow noopener noreferrer',
expectedTarget: '_blank',
expectedTitleAttribute: '',
},
] as const)('should $caseName links', ({ link, query, expectedHref, expectedRel, expectedTarget, expectedTitleAttribute }) => {
const markdownContent = `[Test Link](${link})`;
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
const anchorElement = query();
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent('Test Link');
if (expectedHref !== undefined) expect(anchorElement).toHaveAttribute('href', expectedHref);
else expect(anchorElement).not.toHaveAttribute('href');
if (expectedRel !== undefined) expect(anchorElement).toHaveAttribute('rel', expectedRel);
else expect(anchorElement).not.toHaveAttribute('rel');
if (expectedTarget !== undefined) expect(anchorElement).toHaveAttribute('target', expectedTarget);
else expect(anchorElement).not.toHaveAttribute('target');
if (expectedTitleAttribute !== undefined) expect(anchorElement).toHaveAttribute('title', expectedTitleAttribute);
else expect(anchorElement).not.toHaveAttribute('title');
});
describe('multiple links', () => {
it.each(getTestCases(supportedURISchemes, testUris))('supported scheme: $scheme', ({ scheme, links }) => {
const getTextContent = (link: string) => `Test link - ${link}`;
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
links.forEach((link) => {
const text = getTextContent(link);
const title = `Go to: ${link.replace(MOCKED_BASE_URI, '')}`;
const anchorElement = screen.getByText(text);
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent(text);
expect(anchorElement).toHaveAttribute('href', link);
if (scheme === 'mailto') {
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('href', link);
expect(anchorElement).toHaveAttribute('title', link);
return;
}
if (isExternal(link)) {
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('title', '');
return;
}
expect(anchorElement).toHaveAttribute('title', title);
});
});
it.each(getTestCases(nonSupportedUriSchemes, testUris))('unsupported scheme: $scheme', ({ links }) => {
const getTextContent = (link: string) => `Test link - ${link}`;
const markdownContent = links.map((link) => `[${getTextContent(link)}](${link})`).join('\n');
render(<MarkdownText content={markdownContent} variant='document' />, {
wrapper: mockAppRoot().withTranslations('en', 'core', { Go_to_href: 'Go to: {{href}}' }).build(),
});
links.forEach((link) => {
const text = getTextContent(link);
const anchorElement = screen.getByText(text);
expect(anchorElement).toBeInTheDocument();
expect(anchorElement.tagName).toBe('A');
expect(anchorElement).toHaveTextContent(text);
expect(anchorElement).not.toHaveAttribute('href');
expect(anchorElement).toHaveAttribute('rel', 'nofollow noopener noreferrer');
expect(anchorElement).toHaveAttribute('target', '_blank');
expect(anchorElement).toHaveAttribute('title', '');
});
});
});
});
describe('code handling', () => {
it.each([
{
caseName: 'inline with special characters',
content: '`2 < 3 > 1 & 4 "Test"`',
expected: '<code>2 &lt; 3 &gt; 1 &amp; 4 "Test"</code>',
},
{
caseName: 'inline with encoded special characters',
content: '`< = &lt; > = &gt; & = &amp;`',
expected: '<code>&lt; = &amp;lt; &gt; = &amp;gt; &amp; = &amp;amp;</code>',
},
{
caseName: 'block with language',
content: "```typescript\nconst test = 'this is code'\n```",
expected: '<code class="language-typescript">const test = \'this is code\' </code>',
},
{
caseName: 'block without language',
content: '```\nTwo < Three > One & Four "Test"\n```',
expected: '<code>Two &lt; Three &gt; One &amp; Four "Test" </code>',
},
{
caseName: 'block with encoded special characters',
content: '```\nTwo &lt; Three &gt; One &amp; Four "Test"\n```',
expected: '<code>Two &amp;lt; Three &amp;gt; One &amp;amp; Four "Test" </code>',
},
] as const)('should render $caseName', ({ content, expected }) => {
render(<MarkdownText content={`${content}`} variant='document' />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByRole('code').outerHTML).toEqual(expected);
});
});
describe('line breaks handling', () => {
it('should convert newlines to <br> in document variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).toContain('First Line<br>Second Line<br>Third Line');
});
it('should convert newlines to <br> in inline variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='inline' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).not.toContain('<br>');
});
it('should not convert newlines to <br> in inlineWithoutBreaks variant', () => {
const content = 'First Line\nSecond Line\nThird Line';
const { container } = render(<MarkdownText content={content} variant='inlineWithoutBreaks' />, {
wrapper: mockAppRoot().build(),
});
const html = container.innerHTML;
expect(html).not.toContain('<br>');
});
});
describe('DOMPurify hook registration', () => {
it('should register hook only once at module level', () => {
// Import the module to trigger hook registration
const addHookSpy = jest.spyOn(dompurify, 'addHook');
// Clear any previous calls from module initialization
addHookSpy.mockClear();
const { rerender, unmount } = render(<MarkdownText content='[Test Link](https://example.com)' variant='document' />, {
wrapper: mockAppRoot().build(),
});
// Hook should NOT be registered during component render (it's registered at module level)
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Re-rendering with different props should not register hook again
rerender(<MarkdownText content='[Another Link](https://example.com)' variant='document' />);
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Rendering another instance should not register hook again
render(<MarkdownText content='[Third Link](https://test.com)' variant='inline' />, {
wrapper: mockAppRoot().build(),
});
expect(addHookSpy).toHaveBeenCalledTimes(0);
// Unmounting should not affect the module-level hook
unmount();
expect(addHookSpy).toHaveBeenCalledTimes(0);
addHookSpy.mockRestore();
});
});
describe('parser limit handling', () => {
beforeEach(() => {
getMarkdownParserLimitMock.mockClear();
});
it('should render plain text without parsing when content exceeds the limit', () => {
getMarkdownParserLimitMock.mockReturnValue(5);
const longContent = '**this should not be bold** because it exceeds the limit';
render(<MarkdownText content={longContent} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const element = screen.getByText(longContent);
expect(element).toBeInTheDocument();
expect(element.tagName).not.toBe('STRONG');
});
it('should render parsed markdown when content is within the limit', () => {
getMarkdownParserLimitMock.mockReturnValue(Infinity);
const content = '**bold text**';
render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const boldText = screen.getByText('bold text');
expect(boldText).toBeInTheDocument();
expect(boldText.tagName).toBe('STRONG');
});
it('should render parsed markdown when content length equals the limit', () => {
const content = '**hi**';
getMarkdownParserLimitMock.mockReturnValue(content.length);
render(<MarkdownText content={content} variant='document' />, {
wrapper: mockAppRoot().build(),
});
const parsedText = screen.getByText('hi');
expect(parsedText).toBeInTheDocument();
expect(parsedText.tagName).toBe('STRONG');
});
});
describe('emoji parsing', () => {
// Minimal stand-in for the native emoji package that turns the `:smile:`
// shortcode into the same markup the real renderer produces.
const renderSmile = (text: string): string => text.replace(/:smile:/g, '<span class="emoji" title=":smile:">😄</span>');
beforeAll(() => {
emoji.packages.native = {
emojiCategories: [],
emojisByCategory: {},
toneList: {},
render: renderSmile,
renderPicker: () => undefined,
};
});
afterAll(() => {
delete emoji.packages.native;
});
beforeEach(() => {
getMarkdownParserLimitMock.mockReturnValue(Infinity);
});
it('should render emoji shortcodes that appear in visible text', () => {
const { container } = render(<MarkdownText content='hello :smile:' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const emojiElement = container.querySelector('.emoji');
expect(emojiElement).toBeInTheDocument();
expect(emojiElement).toHaveAttribute('title', ':smile:');
expect(emojiElement).toHaveTextContent('😄');
expect(container).not.toHaveTextContent(':smile:');
});
it('should not corrupt a link whose href contains an emoji shortcode', () => {
render(<MarkdownText content='[docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const anchorElement = screen.getByText('docs');
expect(anchorElement.tagName).toBe('A');
// The shortcode inside the href must stay untouched instead of being replaced by an emoji span
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
expect(anchorElement.querySelector('.emoji')).toBeNull();
});
it('should render emoji in text while keeping a shortcode inside an href intact', () => {
const { container } = render(<MarkdownText content=':smile: [docs](https://example.test/:smile:)' variant='document' parseEmoji />, {
wrapper: mockAppRoot().build(),
});
const anchorElement = screen.getByText('docs');
expect(anchorElement).toHaveAttribute('href', 'https://example.test/:smile:');
expect(anchorElement.querySelector('.emoji')).toBeNull();
const emojiElement = container.querySelector('.emoji');
expect(emojiElement).toBeInTheDocument();
expect(emojiElement).toHaveTextContent('😄');
});
});

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import outdent from 'outdent';
import MarkdownText from './MarkdownText';
const meta = {
component: MarkdownText,
parameters: {
layout: 'padded',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof MarkdownText>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
content: outdent`
# h1 Heading
## h2 Heading
### h3 Heading
#### h4 Heading
##### h5 Heading
###### h6 Heading
___
*This is bold text*
_This is italic text_
~Strikethrough~
+ Lorem ipsum dolor sit amet
+ Consectetur adipiscing elit
+ Integer molestie lorem at massa
1. Lorem ipsum dolor sit amet
2. Consectetur adipiscing elit
3. Integer molestie lorem at massa
\`rocket.chat();\`
https://rocket.chat
`,
},
};
export const Document: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
\`2 < 3 > 1 & 4 "Test"\`
\`< = &lt; > = &gt; & = &amp;\`
\`\`\`
Two < Three > One & Four "Test"
\`\`\`
\`\`\`
< : &lt;
> : &gt;
& : &amp;
\`\`\`
`,
variant: 'document',
},
};
export const Inline: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
`,
variant: 'inline',
},
};
export const InlineWithoutBreaks: Story = {
args: {
content: outdent`
# Title
Paragraph text.
## Subtitle
- List item 1
- List item 2
- List item 3
`,
variant: 'inlineWithoutBreaks',
},
};

View File

@@ -0,0 +1,52 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import MarkdownTextInner from './MarkdownTextInner';
import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
export { supportedURISchemes } from './MarkdownTextInner';
type MarkdownTextParams = {
content: string;
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
preserveHtml: boolean;
parseEmoji: boolean;
withTruncatedText: boolean;
} & ComponentProps<typeof Box>;
export type MarkdownTextProps = Partial<MarkdownTextParams>;
const preserveLineBreaks = css`
white-space: pre-line;
`;
const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => {
if (content && content.length > getMarkdownParserLimit()) {
// `document` parses with `breaks: true`, so its line breaks have to survive the unparsed
// fallback; the inline variants collapse them on purpose, and truncation needs a single line.
const keepLineBreaks = (variant ?? 'document') === 'document' && !withTruncatedText;
return (
<Box
withTruncatedText={withTruncatedText}
{...boxProps}
className={[boxProps.className, keepLineBreaks && preserveLineBreaks].flat()}
>
{content}
</Box>
);
}
return (
<MarkdownTextInner
content={content}
withTruncatedText={withTruncatedText}
variant={variant}
preserveHtml={preserveHtml}
parseEmoji={parseEmoji}
{...boxProps}
/>
);
};
export default MarkdownText;

View File

@@ -0,0 +1,212 @@
import { Box } from '@rocket.chat/fuselage';
import { isExternal, getBaseURI } from '@rocket.chat/ui-client';
import dompurify from 'dompurify';
import { marked } from 'marked';
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji';
type MarkdownTextParams = {
content: string;
variant: 'inline' | 'inlineWithoutBreaks' | 'document';
preserveHtml: boolean;
parseEmoji: boolean;
withTruncatedText: boolean;
} & ComponentProps<typeof Box>;
const documentRenderer = new marked.Renderer();
const inlineRenderer = new marked.Renderer();
const inlineWithoutBreaks = new marked.Renderer();
const walkTokens = (token: marked.Token) => {
const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/;
const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/;
if (boldPattern.test(token.raw) && token.type === 'em') {
token.type = 'strong' as 'em';
} else if (italicPattern.test(token.raw) && token.type === 'strong') {
token.type = 'em' as 'strong';
}
};
marked.use({ walkTokens });
const linkMarked = (href: string | null, _title: string | null, text: string): string => {
return `<a href="${href || ''}">${text}</a>`;
};
const paragraphMarked = (text: string): string => text;
const brMarked = (): string => ' ';
const listItemMarked = (text: string): string => {
const cleanText = text.replace(/<p.*?>|<\/p>/gi, '');
return `<li>${cleanText}</li>`;
};
const horizontalRuleMarked = (): string => '';
const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
if (language) {
return `<pre><code class="language-${language}">${code} </code></pre>`;
}
return `<pre><code>${code} </code></pre>`;
};
const codespanMarked = (code: string): string => {
return `<code>${code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')}</code>`;
};
documentRenderer.link = linkMarked;
documentRenderer.listitem = listItemMarked;
documentRenderer.code = codeMarked;
documentRenderer.codespan = codespanMarked;
inlineRenderer.link = linkMarked;
inlineRenderer.paragraph = paragraphMarked;
inlineRenderer.listitem = listItemMarked;
inlineRenderer.hr = horizontalRuleMarked;
inlineWithoutBreaks.link = linkMarked;
inlineWithoutBreaks.paragraph = paragraphMarked;
inlineWithoutBreaks.br = brMarked;
inlineWithoutBreaks.image = brMarked;
inlineWithoutBreaks.code = paragraphMarked;
inlineWithoutBreaks.codespan = paragraphMarked;
inlineWithoutBreaks.listitem = listItemMarked;
inlineWithoutBreaks.hr = horizontalRuleMarked;
const defaultOptions = {
gfm: true,
headerIds: false,
};
const options = {
...defaultOptions,
breaks: true,
renderer: documentRenderer,
};
const inlineOptions = {
...defaultOptions,
renderer: inlineRenderer,
};
const inlineWithoutBreaksOptions = {
...defaultOptions,
renderer: inlineWithoutBreaks,
};
const getRegexp = (supportedURISchemes: string[]): RegExp => {
const schemes = supportedURISchemes.join('|');
return new RegExp(`^(${schemes}):`, 'im');
};
export type MarkdownTextInnerProps = Partial<MarkdownTextParams>;
export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
// Generate a unique token at runtime to prevent enumeration attacks
// This token marks internal links that need translation
const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
// Register the DOMPurify hook once at module level to prevent memory leaks
// This hook will be shared by all MarkdownText component instances
dompurify.addHook('afterSanitizeAttributes', (node) => {
if (!isLinkElement(node)) {
return;
}
const href = node.getAttribute('href') || '';
const isExternalLink = isExternal(href);
const isMailto = href.startsWith('mailto:');
// Set appropriate attributes based on link type
if (isExternalLink || isMailto) {
node.setAttribute('rel', 'nofollow noopener noreferrer');
// Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
// This attribute must be preserved to guarantee users maintain their chat context
node.setAttribute('target', '_blank');
}
// Set appropriate title based on link type
if (isMailto) {
// For mailto links, use the email address as the title for better user experience
// Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
node.setAttribute('title', href);
} else if (isExternalLink) {
// For external links, set an empty title to prevent tooltips
// This reduces visual clutter and lets users see the URL in the browser's status bar instead
node.setAttribute('title', '');
} else {
// For internal links, use a token that will be replaced with translated text in the component
// This allows us to use the contextualized translation function
const relativePath = href.replace(getBaseURI(), '');
node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
}
});
const MarkdownTextInner = ({
content,
variant = 'document',
withTruncatedText = false,
preserveHtml = false,
parseEmoji = false,
...boxProps
}: MarkdownTextInnerProps) => {
const sanitizer = dompurify.sanitize;
const { t } = useTranslation();
let markedOptions: marked.MarkedOptions;
switch (variant) {
case 'inline':
markedOptions = inlineOptions;
break;
case 'inlineWithoutBreaks':
markedOptions = inlineWithoutBreaksOptions;
break;
case 'document':
default:
markedOptions = options;
}
const __html = useMemo(() => {
const html = ((): any => {
if (content && typeof content === 'string') {
const markedHtml = /inline/.test(variant)
? marked.parseInline(new Option(content).innerHTML, markedOptions)
: marked.parse(new Option(content).innerHTML, markedOptions);
if (parseEmoji) {
// We are using the old emoji parser here. This could come
// with additional processing use, but is the workaround available right now.
// Should be replaced in the future with the new parser.
return renderMessageEmoji(markedHtml);
}
return markedHtml;
}
})();
const sanitizedHtml = preserveHtml
? html
: html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
// Replace internal link tokens with contextualized translations
if (sanitizedHtml && typeof sanitizedHtml === 'string') {
return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
}
return sanitizedHtml;
}, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
return __html ? (
<Box
dangerouslySetInnerHTML={{ __html }}
withTruncatedText={withTruncatedText}
withRichContent={variant === 'inlineWithoutBreaks' ? 'inlineWithoutBreaks' : true}
{...boxProps}
/>
) : null;
};
export default MarkdownTextInner;

View File

@@ -0,0 +1,34 @@
import { Box, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
type NotFoundProps = {
title: string;
subtitle: string;
};
const NotFoundState = ({ title, subtitle }: NotFoundProps) => {
const { t } = useTranslation();
const router = useRouter();
const handleGoHomeClick = () => {
router.navigate('/home');
};
return (
<Box display='flex' justifyContent='center' height='full'>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{subtitle}</StatesSubtitle>
<Box marginBlockStart={16}>
<StatesActions>
<StatesAction onClick={handleGoHomeClick}>{t('Homepage')}</StatesAction>
</StatesActions>
</Box>
</States>
</Box>
);
};
export default NotFoundState;

View File

@@ -0,0 +1,25 @@
import { Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
const PageSkeleton = () => (
<Page>
<PageHeader title={<Skeleton width='x320' maxWidth='full' />}>
<ButtonGroup>
<Button disabled>
<Skeleton width='x80' />
</Button>
</ButtonGroup>
</PageHeader>
<PageContent>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
<Box is='p' color='hint' fontScale='p2'>
<Skeleton />
<Skeleton />
<Skeleton width='75%' />
</Box>
</Box>
</PageContent>
</Page>
);
export default PageSkeleton;

View File

@@ -0,0 +1,216 @@
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import * as React from 'react';
import { Children, forwardRef, isValidElement } from 'react';
import PaginatedVirtualList from './PaginatedVirtualList';
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
scrollTo: jest.fn(),
findItemIndex: jest.fn((offset: number) => offset),
scrollOffset: 0,
scrollSize: 1000,
viewportSize: 300,
};
type MockVListProps = {
children: ReactNode;
bufferSize?: number;
onScroll?: (offset: number) => void;
as?: React.ElementType;
item?: React.ElementType;
style?: CSSProperties;
className?: string;
};
jest.mock('virtua', () => {
return {
Virtualizer: React.forwardRef(
(
{ children, bufferSize, onScroll, as: asRoot = 'div', item: asItem = 'div', style, className }: MockVListProps,
ref: React.Ref<unknown>,
) => {
React.useImperativeHandle(ref, () => mockVirtualizerHandle);
const Root = asRoot;
const Item = asItem;
const wrapped = Children.map(children, (child, index) => {
const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`;
return <Item key={key}>{child}</Item>;
});
return (
<Root
className={className}
data-buffer-size={bufferSize}
data-testid='virtual-list'
style={style ?? { height: '100%' }}
onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)}
>
{wrapped}
</Root>
);
},
),
};
});
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
{ children, ...props },
ref,
) {
// eslint-disable-next-line testing-library/no-node-access
const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children;
return (
<div ref={ref} {...props}>
{content}
</div>
);
}),
}));
const items = Array.from({ length: 10 }, (_, index) => ({ _id: `${index}` }));
type VirtualListTestItem = (typeof items)[number];
const renderVirtualList = (
props: Partial<{
items: VirtualListTestItem[];
totalCount: number;
renderItem: (item: VirtualListTestItem, index: number) => ReactNode;
overscan?: number;
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
}> = {},
) => render(<PaginatedVirtualList items={items} totalCount={20} renderItem={(item) => <div>{item._id}</div>} {...props} />);
const advanceDebouncedScroll = async () => {
await act(async () => {
await jest.advanceTimersByTimeAsync(300);
});
};
describe('PaginatedVirtualList', () => {
beforeEach(() => {
mockVirtualizerHandle.scrollOffset = 0;
mockVirtualizerHandle.scrollSize = 1000;
mockVirtualizerHandle.viewportSize = 300;
});
afterEach(() => {
jest.useRealTimers();
});
it('has no accessibility violations', async () => {
const { container } = renderVirtualList();
expect(await axe(container)).toHaveNoViolations();
});
it('calls onEndReached when scrolled near the bottom', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached });
expect(onEndReached).not.toHaveBeenCalled();
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(1);
});
it('does not call onEndReached when all items are loaded', () => {
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached, totalCount: items.length });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
expect(onEndReached).not.toHaveBeenCalled();
});
it('does not call onEndReached repeatedly for the same item count', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(1);
});
it('calls onEndReached after a same-size dataset reset', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockResolvedValue(undefined);
const { rerender } = renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
const resetItems = Array.from({ length: 10 }, (_, index) => ({ _id: `reset-${index}` }));
rerender(
<PaginatedVirtualList items={resetItems} totalCount={20} renderItem={(item) => <div>{item._id}</div>} onEndReached={onEndReached} />,
);
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('passes overscan through to virtua buffer size', () => {
renderVirtualList({ overscan: 25 });
expect(screen.getByTestId('virtual-list')).toHaveAttribute('data-buffer-size', '25');
});
it('allows onEndReached to retry after a failed load', async () => {
jest.useFakeTimers();
const onEndReached = jest.fn().mockRejectedValue(new Error('failed to load more items'));
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('allows onEndReached to retry after a synchronous throw', async () => {
jest.useFakeTimers();
const onEndReached = jest
.fn()
.mockImplementationOnce(() => {
throw new Error('failed to load more items');
})
.mockImplementation(() => undefined);
renderVirtualList({ onEndReached });
mockVirtualizerHandle.scrollOffset = 700;
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
fireEvent.scroll(screen.getByTestId('virtual-list'));
await advanceDebouncedScroll();
expect(onEndReached).toHaveBeenCalledTimes(2);
});
it('calls onEndReached when the viewport is underfilled', () => {
const onEndReached = jest.fn().mockResolvedValue(undefined);
mockVirtualizerHandle.scrollSize = 200;
renderVirtualList({ onEndReached });
expect(onEndReached).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,103 @@
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { VirtualizerHandle } from 'virtua';
import { Virtualizer } from 'virtua';
import { VirtuaListContainer } from './VirtuaListContainer';
const NEAR_BOTTOM_THRESHOLD = -20;
const scrollViewportStyle = {
height: '100%',
width: '100%',
overflow: 'auto',
} as const;
type PaginatedVirtualListProps<T extends { _id: string }> = {
items: T[];
totalCount: number;
renderItem: (item: T, index: number) => ReactNode;
overscan?: number;
onEndReached?: UseInfiniteQueryResult['fetchNextPage'];
};
function PaginatedVirtualList<T extends { _id: string }>({
items,
totalCount,
renderItem,
overscan,
onEndReached,
}: PaginatedVirtualListProps<T>) {
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const isEndReachedLockedRef = useRef(false);
const firstItemId = items[0]?._id ?? '';
const lastItemId = items[items.length - 1]?._id ?? '';
const checkEndReached = useCallback(
async (offset: number) => {
if (isEndReachedLockedRef.current) {
return;
}
const handle = virtualizerRef.current;
if (!handle || !onEndReached) {
return;
}
const { scrollSize, viewportSize } = handle;
if (viewportSize <= 0 || items.length >= totalCount) {
return;
}
const nearBottom = offset - scrollSize + viewportSize >= NEAR_BOTTOM_THRESHOLD;
if (!nearBottom) {
return;
}
isEndReachedLockedRef.current = true;
try {
await onEndReached();
} catch {
isEndReachedLockedRef.current = false;
}
},
[items.length, onEndReached, totalCount],
);
const handleScroll = useDebouncedCallback(
(offset: number) => {
checkEndReached(offset);
},
300,
[checkEndReached],
);
useLayoutEffect(() => {
isEndReachedLockedRef.current = false;
const handle = virtualizerRef.current;
if (!handle) {
return;
}
checkEndReached(handle.scrollOffset);
}, [checkEndReached, firstItemId, items.length, lastItemId, totalCount]);
return (
<CustomVirtuaScrollbars>
<div style={scrollViewportStyle}>
<Virtualizer ref={virtualizerRef} as={VirtuaListContainer} item='li' bufferSize={overscan} onScroll={handleScroll}>
{items.map((item, index) => (
<div key={item._id}>{renderItem(item, index)}</div>
))}
</Virtualizer>
</div>
</CustomVirtuaScrollbars>
);
}
export default PaginatedVirtualList;

View File

@@ -0,0 +1,24 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
const listResetStyle = {
margin: 0,
padding: 0,
listStyle: 'none',
} as const;
export type VirtuaListContainerProps = {
children: ReactNode;
style: CSSProperties;
} & Omit<HTMLAttributes<HTMLUListElement>, 'children' | 'style'>;
export const VirtuaListContainer = forwardRef<HTMLUListElement, VirtuaListContainerProps>(function VirtuaListContainer(
{ children, style, ...props },
ref,
) {
return (
<ul {...props} ref={ref} style={{ ...listResetStyle, ...style }}>
{children}
</ul>
);
});

View File

@@ -0,0 +1 @@
export { default as PaginatedVirtualList } from './PaginatedVirtualList';

View File

@@ -0,0 +1,26 @@
import { Box, Tag } from '@rocket.chat/fuselage';
import { isTruthy } from '@rocket.chat/tools';
import { useLicense } from '@rocket.chat/ui-client';
const developmentTag = process.env.NODE_ENV === 'development' ? 'Development' : null;
function PlanTag() {
const license = useLicense();
const tags = [
developmentTag && { name: developmentTag },
...(license.data?.tags ?? []),
!license.isLoading && !license.isError && !license.data?.license && { name: 'Community' },
].filter(isTruthy);
return (
<>
{tags.map(({ name }) => (
<Box marginInline={4} display='inline-block' verticalAlign='middle' key={name}>
<Tag variant='primary'>{name}</Tag>
</Box>
))}
</>
);
}
export default PlanTag;

View File

@@ -0,0 +1,8 @@
import DOMPurify from 'dompurify';
/** @deprecated */
export type RawTextProps = { children: string };
const RawText = ({ children }: RawTextProps) => <span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(children) }} />;
export default RawText;

View File

@@ -0,0 +1,28 @@
import { VisuallyHidden } from '@react-aria/visually-hidden';
import { useTranslation } from 'react-i18next';
const ResultsLiveRegion = ({
shouldAnnounce,
itemCount,
isLoading = false,
}: {
shouldAnnounce: boolean;
itemCount: number;
isLoading?: boolean;
}) => {
const { t } = useTranslation();
const message = (() => {
if (isLoading) {
return t('Loading');
}
if (!shouldAnnounce) {
return '';
}
return itemCount === 0 ? t('No_results_found') : t('__count__result_found', { count: itemCount });
})();
return <VisuallyHidden role='status'>{message}</VisuallyHidden>;
};
export default ResultsLiveRegion;

View File

@@ -0,0 +1,107 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Option, Box } from '@rocket.chat/fuselage';
import type { AutoCompleteProps } from '@rocket.chat/fuselage';
import { AutoComplete } from '@rocket.chat/fuselage-forms';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { forwardRef, memo, useMemo, useState } from 'react';
type LabelType = { name: string; avatarETag?: string; type: IRoom['t']; encrypted?: IRoom['encrypted'] };
const generateQuery = (
term = '',
): {
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
export type RoomAutoCompleteProps = Omit<AutoCompleteProps<LabelType>, 'filter'> & {
scope?: 'admin' | 'regular';
renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactNode;
setSelectedRoom?: (room: IRoom | undefined) => void;
};
const AVATAR_SIZE = 'x20';
const ROOM_AUTOCOMPLETE_PARAMS = {
admin: {
endpoint: '/v1/rooms.autocomplete.adminRooms',
key: 'roomsAutoCompleteAdmin',
},
regular: {
endpoint: '/v1/rooms.autocomplete.channelAndPrivate',
key: 'roomsAutoCompleteRegular',
},
} as const;
const RoomAutoComplete = forwardRef<HTMLInputElement, RoomAutoCompleteProps>(function RoomAutoComplete(
{ value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props },
ref,
) {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const roomsAutoCompleteEndpoint = useEndpoint('GET', ROOM_AUTOCOMPLETE_PARAMS[scope].endpoint);
const result = useQuery({
queryKey: [ROOM_AUTOCOMPLETE_PARAMS[scope].key, filterDebounced],
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
});
const options = useMemo(
() =>
result.isSuccess
? result.data.items.map(({ name, fname, _id, avatarETag, t, encrypted }) => ({
value: _id,
label: { name: fname || name, avatarETag, type: t, encrypted },
}))
: [],
[result.data?.items, result.isSuccess],
);
return (
<AutoComplete
{...props}
ref={ref}
value={value}
onChange={(val) => {
onChange(val);
if (setSelectedRoom && typeof setSelectedRoom === 'function') {
const selectedRoom = result?.data?.items.find(({ _id }) => _id === val) as unknown as IRoom;
setSelectedRoom(selectedRoom);
}
}}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label } }) => (
<>
<Box margin='none' marginInline={2}>
<RoomAvatar size={AVATAR_SIZE} room={{ ...label, type: label?.type || 'c', _id: value }} />
</Box>
<Box margin='none' marginInline={2}>
{label?.name}
</Box>
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
</>
)}
renderItem={({ value, label, ...props }) => (
<Option
{...props}
label={
<>
{label?.name}
{renderRoomIcon?.({ encrypted: label?.encrypted, type: label?.type })}
</>
}
avatar={<RoomAvatar size={AVATAR_SIZE} room={{ _id: value, ...label }} />}
/>
)}
options={options}
/>
);
});
export default memo(RoomAutoComplete);

View File

@@ -0,0 +1 @@
export { default } from './RoomAutoComplete';

View File

@@ -0,0 +1,74 @@
import { AutoComplete, Option, Chip, Box, Skeleton } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
const generateQuery = (
term = '',
): {
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
type RoomAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'> & {
readOnly?: boolean;
};
const RoomAutoCompleteMultiple = ({ value, onChange, ...props }: RoomAutoCompleteProps) => {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const autocomplete = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate');
const result = useQuery({
queryKey: ['rooms.autocomplete.channelAndPrivate', filterDebounced],
queryFn: () => autocomplete(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
});
const options = useMemo(
() =>
result.isSuccess
? result.data.items.map(({ fname, name, _id, avatarETag, t }) => ({
value: _id,
label: { name: fname || name, avatarETag, type: t },
}))
: [],
[result.data?.items, result.isSuccess],
);
if (result.isPending) {
return <Skeleton />;
}
return (
<AutoComplete
{...props}
value={value}
onChange={onChange}
filter={filter}
setFilter={setFilter}
multiple
renderSelected={({ selected: { value, label }, onRemove, ...props }) => (
<Chip {...props} key={value} value={value} onClick={onRemove}>
<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />
<Box is='span' margin='none' marginInlineStart={4}>
{label?.name}
</Box>
</Chip>
)}
renderItem={({ value, label, ...props }) => (
<Option
key={value}
{...props}
label={label.name}
avatar={<RoomAvatar size='x20' room={{ ...label, type: label?.type || 'c', _id: value }} />}
/>
)}
options={options}
/>
);
};
export default memo(RoomAutoCompleteMultiple);

View File

@@ -0,0 +1 @@
export { default } from './RoomAutoCompleteMultiple';

View File

@@ -0,0 +1,29 @@
import type { IOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
import { Icon, Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useOmnichannelRoomIcon } from './context/OmnichannelRoomIconContext';
export type OmnichannelAppSourceRoomIconProps = {
source: IOmnichannelSourceFromApp;
color: ComponentProps<typeof Box>['color'];
size: ComponentProps<typeof Icon>['size'];
placement: 'sidebar' | 'default';
};
export const OmnichannelAppSourceRoomIcon = ({ source, color, size, placement }: OmnichannelAppSourceRoomIconProps) => {
const icon = (placement === 'sidebar' && source.sidebarIcon) || source.defaultIcon;
const value = useOmnichannelRoomIcon(source.id, icon || '');
if (!value) {
return <Icon name='headset' size={size} color={color} />;
}
return (
<Box size={size} color={color}>
<Box is='svg' size={size} aria-hidden='true'>
<Box is='use' href={`#${value}`} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,23 @@
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const iconMap = {
widget: 'livechat',
email: 'mail',
sms: 'sms',
app: 'headset',
api: 'headset',
other: 'headset',
} as const;
export type OmnichannelCoreSourceRoomIconProps = {
source: IOmnichannelSource;
color: ComponentProps<typeof Icon>['color'];
size: ComponentProps<typeof Icon>['size'];
};
export const OmnichannelCoreSourceRoomIcon = ({ source, color, size }: OmnichannelCoreSourceRoomIconProps) => {
const icon = iconMap[source?.type || 'other'] || 'headset';
return <Icon name={icon} size={size} color={color} />;
};

View File

@@ -0,0 +1,33 @@
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
import { UserStatus, isOmnichannelSourceFromApp } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { OmnichannelAppSourceRoomIcon } from './OmnichannelAppSourceRoomIcon';
import { OmnichannelCoreSourceRoomIcon } from './OmnichannelCoreSourceRoomIcon';
const colors = {
busy: 'status-font-on-danger',
away: 'status-font-on-warning',
online: 'status-font-on-success',
offline: 'annotation',
disabled: 'annotation',
} as const;
export type OmnichannelRoomIconProps = {
source: IOmnichannelSource;
color?: ComponentProps<typeof Icon>['color'];
status?: UserStatus;
size: ComponentProps<typeof Icon>['size'];
placement?: 'sidebar' | 'default';
};
export const OmnichannelRoomIcon = ({ source, color, status, size = 'x16', placement = 'default' }: OmnichannelRoomIconProps) => {
const iconColor = color ?? colors[status || UserStatus.OFFLINE];
if (isOmnichannelSourceFromApp(source)) {
return <OmnichannelAppSourceRoomIcon source={source} placement={placement} color={iconColor} size={size} />;
}
return <OmnichannelCoreSourceRoomIcon source={source} color={iconColor} size={size} />;
};

View File

@@ -0,0 +1,15 @@
import { createContext, useMemo, useContext, useSyncExternalStore } from 'react';
type IOmnichannelRoomIconContext = {
queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined];
};
export const OmnichannelRoomIconContext = createContext<IOmnichannelRoomIconContext>({
queryIcon: () => [(): (() => void) => (): void => undefined, () => undefined],
});
export const useOmnichannelRoomIcon = (app: string, icon: string): string | undefined => {
const { queryIcon } = useContext(OmnichannelRoomIconContext);
const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]);
return useSyncExternalStore(subscribe, getSnapshot);
};

View File

@@ -0,0 +1 @@
export * from './OmnichannelRoomIcon';

View File

@@ -0,0 +1,47 @@
import { Emitter } from '@rocket.chat/emitter';
import DOMPurify from 'dompurify';
import { sdk } from '../../../../lib/SDKClient';
const OmnichannelRoomIconManager = new (class extends Emitter {
icons = new Map<string, string>();
constructor() {
super();
}
public get(appId: string, icon: string): string | undefined {
if (!appId || !icon) {
return;
}
if (this.icons.has(`${appId}-${icon}`)) {
return `${appId}-${icon}`;
}
// TODO: update the apps icons to send JSON instead of a string. This will allow us to use APIClient.get()
sdk.rest
.send(`/apps/public/${appId}/get-sidebar-icon?icon=${icon}`, 'GET')
.then((response: any) => {
response.text().then((text: string) => {
this.icons.set(
`${appId}-${icon}`,
DOMPurify.sanitize(text, {
FORBID_ATTR: ['id'],
NAMESPACE: 'http://www.w3.org/2000/svg',
USE_PROFILES: { svg: true, svgFilters: true },
})
.replace(`<svg`, `<symbol id="${appId}-${icon}"`)
.replace(`</svg>`, '</symbol>'),
);
this.emit('change');
this.emit(`${appId}-${icon}`);
});
})
.catch((error: any) => {
console.error('error from get-sidebar-icon', error);
});
}
})();
export default OmnichannelRoomIconManager;

View File

@@ -0,0 +1,81 @@
import DOMPurify from 'dompurify';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext';
import OmnichannelRoomIconManager from '../lib/OmnichannelRoomIconManager';
let icons = Array.from(OmnichannelRoomIconManager.icons.values());
export type OmnichannelRoomIconProviderProps = {
children?: ReactNode;
};
export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconProviderProps) => {
const svgIcons = useSyncExternalStore(
useCallback(
(callback): (() => void) =>
OmnichannelRoomIconManager.on('change', () => {
icons = Array.from(OmnichannelRoomIconManager.icons.values());
callback();
}),
[],
),
(): string[] => icons,
);
return (
<OmnichannelRoomIconContext.Provider
value={useMemo(() => {
const extractSnapshot = (app: string, iconName: string) => OmnichannelRoomIconManager.get(app, iconName);
// We cache all the icons here, so that we can use them in the OmnichannelRoomIcon component
const snapshots = new Map<string, string | undefined>();
return {
queryIcon: (
app: string,
iconName: string,
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined] => [
(callback): (() => void) =>
OmnichannelRoomIconManager.on(`${app}-${iconName}`, () => {
snapshots.set(`${app}-${iconName}`, extractSnapshot(app, iconName));
// Then we call the callback (onStoreChange), signaling React to re-render
callback();
}),
// No problem here, because it's return value is a cached in the snapshots map on subsequent calls
() => {
let snapshot = snapshots.get(`${app}-${iconName}`);
if (!snapshot) {
snapshot = extractSnapshot(app, iconName);
snapshots.set(`${app}-${iconName}`, snapshot);
}
return snapshot;
},
],
};
}, [])}
>
{createPortal(
<svg
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
style={{ display: 'none' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(`<svg>${svgIcons.join('')}</svg>`, {
USE_PROFILES: { svg: true, svgFilters: true },
}).slice(5, -6),
}}
/>,
document.body,
'custom-icons',
)}
{children}
</OmnichannelRoomIconContext.Provider>
);
};

View File

@@ -0,0 +1,40 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { isValidElement } from 'react';
import { OmnichannelRoomIcon } from './OmnichannelRoomIcon';
import { useRoomIcon } from '../../hooks/useRoomIcon';
export const RoomIcon = ({
room,
size = 'x16',
isIncomingCall,
placement = 'default',
}: {
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u'>;
size?: ComponentProps<typeof Icon>['size'];
isIncomingCall?: boolean;
placement?: 'sidebar' | 'default';
}) => {
const iconPropsOrReactNode = useRoomIcon(room);
if (isIncomingCall) {
return <Icon name='phone' size={size} />;
}
if (isOmnichannelRoom(room)) {
return <OmnichannelRoomIcon placement={placement} source={room.source} status={room.v?.status} size={size} />;
}
if (isValidElement<any>(iconPropsOrReactNode)) {
return iconPropsOrReactNode;
}
if (!iconPropsOrReactNode) {
return null;
}
return <Icon {...iconPropsOrReactNode} size={size} />;
};

View File

@@ -0,0 +1 @@
export * from './RoomIcon';

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
import { CustomScrollbars } from '@rocket.chat/ui-client';
import type { ComponentPropsWithoutRef } from 'react';
export type ContentProps = ComponentPropsWithoutRef<typeof CustomScrollbars>;
const Content = ({ children, ...props }: ContentProps) => (
<Box display='flex' flexDirection='column' flexGrow={1} flexShrink={1} overflow='hidden'>
<CustomScrollbars {...props}>
<Box display='flex' flexDirection='column' width='full' minHeight='full'>
{children}
</Box>
</CustomScrollbars>
</Box>
);
export default Content;

View File

@@ -0,0 +1,31 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type HeaderProps = {
children?: ReactNode;
title?: ReactNode;
onClose?: () => void;
};
const Header = ({ title, onClose, children, ...props }: HeaderProps) => {
const { t } = useTranslation();
return (
<Box display='flex' flexDirection='column' paddingBlock={16} {...props}>
{(title || onClose) && (
<Box display='flex' flexDirection='row' alignItems='center' paddingInline={24} justifyContent='space-between' flexGrow={1}>
{title && (
<Box color='default' fontScale='p2b' flexShrink={1} withTruncatedText>
{title}
</Box>
)}
{onClose && <IconButton small aria-label={t('Close')} icon='cross' onClick={onClose} />}
</Box>
)}
{children}
</Box>
);
};
export default Header;

View File

@@ -0,0 +1,33 @@
import { Option, OptionColumn, OptionContent, OptionIcon, OptionInput } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';
type ListItemCommonProps = {
text: ReactNode;
input?: ReactNode;
loading?: boolean;
children?: ReactNode;
} & ComponentProps<typeof Option>;
type ListItemConditionalProps =
| {
icon?: ComponentProps<typeof OptionIcon>['name'];
gap?: never;
}
| {
icon?: never;
gap?: boolean;
};
export type ListItemProps = ListItemCommonProps & ListItemConditionalProps;
const ListItem = ({ icon, text, input, children, gap, ...props }: ListItemProps) => (
<Option {...props}>
{icon && <OptionIcon name={icon} />}
{gap && <OptionColumn />}
<OptionContent>{text}</OptionContent>
{input && <OptionInput>{input}</OptionInput>}
{children && <OptionColumn>{children}</OptionColumn>}
</Option>
);
export default ListItem;

View File

@@ -0,0 +1,8 @@
import { Sidebar as FuselageSidebar } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type SidebarProps = ComponentPropsWithoutRef<typeof FuselageSidebar>;
const Sidebar = (props: SidebarProps) => <FuselageSidebar {...props} />;
export default Sidebar;

View File

@@ -0,0 +1,20 @@
import { Box, SidebarItem } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { memo } from 'react';
export type SidebarGenericItemProps = {
href?: string;
active?: boolean;
children: ReactNode;
externalUrl?: boolean;
};
const SidebarGenericItem = ({ href, active, externalUrl, children, ...props }: SidebarGenericItemProps) => (
<SidebarItem selected={active} is='a' href={href} {...(externalUrl && { target: '_blank', rel: 'noopener noreferrer' })} {...props}>
<Box display='flex' flexDirection='row' alignItems='center' paddingBlock={8} width='100%'>
{children}
</Box>
</SidebarItem>
);
export default memo(SidebarGenericItem);

View File

@@ -0,0 +1,41 @@
import { Divider } from '@rocket.chat/fuselage';
import { Fragment, memo } from 'react';
import { useTranslation } from 'react-i18next';
import SidebarNavigationItem from './SidebarNavigationItem';
import type { SidebarItem } from '../../lib/createSidebarItems';
import { isSidebarItem } from '../../lib/createSidebarItems';
export type SidebarItemsAssemblerProps = {
items: SidebarItem[];
currentPath?: string;
};
const SidebarItemsAssembler = ({ items, currentPath }: SidebarItemsAssemblerProps) => {
const { t, i18n } = useTranslation();
return (
<>
{items.map((props) => (
<Fragment key={props.i18nLabel}>
{isSidebarItem(props) ? (
<SidebarNavigationItem
permissionGranted={props.permissionGranted}
pathSection={props.href ?? props.pathSection ?? ''}
icon={props.icon}
label={t((props.i18nLabel || props.name) as Parameters<typeof t>[0])}
currentPath={currentPath}
tag={props.tag && i18n.exists(props.tag) ? t(props.tag) : props.tag}
externalUrl={props.externalUrl}
badge={props.badge}
/>
) : (
<Divider />
)}
</Fragment>
))}
</>
);
};
export default memo(SidebarItemsAssembler);

View File

@@ -0,0 +1,57 @@
import { Box, Icon, Tag } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { ReactNode } from 'react';
import { memo } from 'react';
import SidebarGenericItem from './SidebarGenericItem';
export type SidebarNavigationItemProps = {
permissionGranted?: (() => boolean) | boolean;
pathSection: string;
icon?: IconName;
label?: string;
tag?: string;
currentPath?: string;
externalUrl?: boolean;
badge?: () => ReactNode;
};
const SidebarNavigationItem = ({
permissionGranted,
pathSection,
icon,
label,
currentPath,
tag,
externalUrl,
// eslint-disable-next-line @typescript-eslint/naming-convention
badge: Badge,
}: SidebarNavigationItemProps) => {
const path = pathSection;
const isActive = !!path && currentPath?.includes(path);
if (permissionGranted === false || (typeof permissionGranted === 'function' && !permissionGranted())) {
return null;
}
return (
<SidebarGenericItem active={isActive} href={path} externalUrl={externalUrl} aria-current={isActive ? 'page' : undefined}>
{icon && <Icon name={icon} size='x20' marginInline={4} />}
<Box
withTruncatedText
fontScale='p2'
marginInline={4}
flexGrow={1}
display='flex'
alignItems='center'
justifyContent='space-between'
width='100%'
>
{label} {tag && <Tag>{tag}</Tag>}
</Box>
{Badge ? <Badge /> : null}
</SidebarGenericItem>
);
};
export default memo(SidebarNavigationItem);

View File

@@ -0,0 +1,16 @@
import Content from './Content';
import Header from './Header';
import ListItem from './ListItem';
import Sidebar from './Sidebar';
import GenericItem from './SidebarGenericItem';
import SidebarItemsAssembler from './SidebarItemsAssembler';
import NavigationItem from './SidebarNavigationItem';
export default Object.assign(Sidebar, {
Content,
Header,
GenericItem,
NavigationItem,
ItemsAssembler: SidebarItemsAssembler,
ListItem,
});

View File

@@ -0,0 +1,24 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
import { useLayout, useSession } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import SidebarTogglerButton from './SidebarTogglerButton';
const SideBarToggler = () => {
const { sidebar } = useLayout();
const isLayoutEmbedded = useEmbeddedLayout();
const unreadMessagesBadge = useSession('unread') as number | string | undefined;
const toggleSidebar = useStableCallback(() => sidebar.toggle());
return (
<SidebarTogglerButton
pressed={!sidebar.isCollapsed}
onClick={toggleSidebar}
badge={!isLayoutEmbedded && unreadMessagesBadge && unreadMessagesBadge}
/>
);
};
export default memo(SideBarToggler);

View File

@@ -0,0 +1,22 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Badge } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type SidebarTogglerBadgeProps = {
children?: ReactNode;
};
const SidebarTogglerBadge = ({ children }: SidebarTogglerBadgeProps) => (
<Box
className={css`
position: absolute;
z-index: 3;
top: -5px;
right: -5px;
`}
>
<Badge variant='danger'>{children}</Badge>
</Box>
);
export default SidebarTogglerBadge;

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryFn } from '@storybook/react';
import { action } from 'storybook/actions';
import SidebarTogglerButton from './SidebarTogglerButton';
export default {
component: SidebarTogglerButton,
parameters: {
layout: 'centered',
controls: { hideNoControlsWarning: true },
actions: { argTypesRegex: '^on.*' },
},
} satisfies Meta<typeof SidebarTogglerButton>;
export const Example: StoryFn<typeof SidebarTogglerButton> = () => <SidebarTogglerButton onClick={action('onClick')} />;
export const Default = {};
export const WithBadge = {
args: {
badge: 99,
},
};

View File

@@ -0,0 +1,24 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import SidebarTogglerBadge from './SidebarTogglerBadge';
export type SideBarTogglerButtonProps = {
pressed?: boolean;
badge?: ReactNode;
onClick: () => void;
};
const SideBarTogglerButton = ({ pressed, badge, onClick }: SideBarTogglerButtonProps) => {
const { t } = useTranslation();
return (
<Box position='relative'>
<IconButton title={!pressed ? t('Open_sidebar') : t('Close_sidebar')} icon='burger-menu' pressed={pressed} small onClick={onClick} />
{badge && <SidebarTogglerBadge>{badge}</SidebarTogglerBadge>}
</Box>
);
};
export default SideBarTogglerButton;

View File

@@ -0,0 +1 @@
export { default } from './SidebarToggler';

View File

@@ -0,0 +1,13 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
export const FormSkeleton = (props: ComponentProps<typeof Box>) => (
<Box width='full' paddingBlock={24} {...props}>
<Skeleton marginBlockEnd={8} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={8} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={8} />
</Box>
);

View File

@@ -0,0 +1,53 @@
import { Box, Button, Scrollable } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import useClipboardWithToast from '../hooks/useClipboardWithToast';
const defaultWrapperRenderer = (text: string) => (
<Box
fontFamily='mono'
alignSelf='center'
fontScale='p2'
style={{ wordBreak: 'break-all' }}
marginInlineEnd={4}
flexGrow={1}
maxHeight='x108'
>
{text}
</Box>
);
export type TextCopyProps = {
text: string;
wrapper?: (text: string) => ReactNode;
} & ComponentProps<typeof Box>;
const TextCopy = ({ text, wrapper = defaultWrapperRenderer, ...props }: TextCopyProps) => {
const { t } = useTranslation();
const { copy } = useClipboardWithToast(text);
const handleClick = () => {
copy();
};
return (
<Box
display='flex'
flexDirection='row'
justifyContent='stretch'
alignItems='flex-start'
flexGrow={1}
paddingBlock={16}
backgroundColor='surface'
width='full'
{...props}
>
<Scrollable vertical>{wrapper(text)}</Scrollable>
<Button icon='copy' secondary square small flexShrink={0} onClick={handleClick} title={t('Copy')} />
</Box>
);
};
export default TextCopy;

View File

@@ -0,0 +1,132 @@
import { Box, Button } from '@rocket.chat/fuselage';
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { OnConfirm } from './TwoFactorModal';
import { Method } from './TwoFactorModal';
export type TwoFactorEmailModalProps = {
onConfirm: OnConfirm;
onClose: () => void;
invalidAttempt?: boolean;
} & (
| {
emailOrUsername: string;
challengeId?: never;
}
| {
challengeId: string;
emailOrUsername?: never;
}
);
type TwoFactorEmailFormData = {
code: string;
};
const TwoFactorEmailModal = ({ onConfirm, onClose, invalidAttempt, emailOrUsername, challengeId }: TwoFactorEmailModalProps) => {
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const {
control,
handleSubmit,
setError,
setValue,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<TwoFactorEmailFormData>({
defaultValues: { code: '' },
});
useEffect(() => {
if (invalidAttempt) {
setError('code', {
type: 'manual',
message: t('Invalid_two_factor_code'),
});
}
}, [invalidAttempt, setError, t]);
const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode');
const sendEmailCodeByChallengeId = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode');
const onClickResendCode = async (): Promise<void> => {
try {
if (emailOrUsername) {
await sendEmailCode({ emailOrUsername });
} else if (challengeId) {
await sendEmailCodeByChallengeId({ challengeId });
}
dispatchToastMessage({ type: 'success', message: t('Email_sent') });
} catch (error) {
dispatchToastMessage({
type: 'error',
message: t('error-email-send-failed', { message: error }),
});
}
};
const onSubmit = handleSubmit(async ({ code }) => {
try {
await onConfirm(code, Method.EMAIL);
} catch (error) {
setError('code', {
type: 'manual',
message: t('Invalid_two_factor_code'),
});
setValue('code', '');
}
});
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={onSubmit} {...props} />}
onCancel={onClose}
confirmText={t('Verify')}
title={t('Enter_authentication_code')}
onClose={onClose}
variant='warning'
confirmDisabled={isSubmitting}
tagline={t('Email_two-factor_authentication')}
icon={null}
>
<FieldGroup>
<Field>
<FieldLabel alignSelf='stretch'>{t('Enter_the_code_we_just_emailed_you')}</FieldLabel>
<FieldRow>
<Controller
name='code'
control={control}
rules={{ required: t('Required_field', { field: t('Code') }) }}
render={({ field: { onChange, ...fieldProps } }) => (
<TextInput
{...fieldProps}
onChange={(e) => {
clearErrors('code');
onChange(e);
}}
placeholder={t('Enter_code_here')}
autoComplete='one-time-code'
inputMode='numeric'
disabled={isSubmitting}
error={errors.code?.message}
/>
)}
/>
</FieldRow>
{errors.code && <FieldError>{errors.code.message}</FieldError>}
</Field>
</FieldGroup>
<Button display='flex' justifyContent='end' onClick={onClickResendCode} small marginBlockStart={24}>
{t('Cloud_resend_email')}
</Button>
</GenericModal>
);
};
export default TwoFactorEmailModal;

Some files were not shown because too many files have changed in this diff Show More