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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (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,22 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './MessageComposer.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 />, {
wrapper: mockAppRoot().build(),
});
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,163 @@
import { Button, IconButton } from '@rocket.chat/fuselage';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import {
MessageComposer,
MessageComposerAction,
MessageComposerToolbarActions,
MessageComposerInput,
MessageComposerToolbar,
MessageComposerActionsDivider,
MessageComposerToolbarSubmit,
MessageComposerSkeleton,
MessageComposerHint,
MessageComposerInputExpandable,
MessageComposerFile,
MessageComposerFileGroup,
MessageComposerFileError,
} from '.';
export default {
component: MessageComposer,
} satisfies Meta<typeof MessageComposer>;
const MessageToolbarActions = () => (
<MessageComposerToolbarActions>
<MessageComposerAction title='emoji' icon='emoji' />
<MessageComposerActionsDivider />
<MessageComposerAction title='bold' icon='bold' />
<MessageComposerAction title='italic' icon='italic' />
<MessageComposerAction title='underline' icon='underline' />
<MessageComposerAction title='strike' icon='strike' />
<MessageComposerAction title='code' icon='code' />
<MessageComposerAction title='multiline' icon='multiline' />
<MessageComposerAction title='link' icon='link' />
<MessageComposerAction title='katex' icon='katex' />
<MessageComposerActionsDivider />
<MessageComposerAction title='mic' icon='mic' />
<MessageComposerAction title='video' icon='video' />
<MessageComposerAction title='attachment' icon='clip' />
<MessageComposerAction title='more' icon='plus' />
</MessageComposerToolbarActions>
);
export const Default: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageComposer>
<MessageComposerInput placeholder='Text' />
<MessageComposerToolbar>
<MessageToolbarActions />
</MessageComposerToolbar>
</MessageComposer>
),
};
export const Expandable: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageComposer>
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 120,
}}
placeholder='Type a message...'
/>
<MessageComposerToolbar>
<MessageToolbarActions />
</MessageComposerToolbar>
</MessageComposer>
),
};
export const ToolbarActions: StoryObj<typeof MessageComposerToolbarActions> = {
render: () => <MessageToolbarActions />,
};
export const WithHints: StoryObj<typeof MessageComposer> = {
render: () => (
<>
<MessageComposerHint
icon='pencil'
helperText={
<>
<strong>esc</strong> to cancel · <strong>enter</strong> to save
</>
}
>
Editing message
</MessageComposerHint>
<MessageComposer>
<MessageComposerInput placeholder='Text' />
<MessageComposerToolbar>
<MessageToolbarActions />
<MessageComposerToolbarSubmit>
<MessageComposerAction aria-label='Send' icon='send' disabled={false} secondary={true} info={true} />
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
</>
),
};
export const WithSubmit: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageComposer>
<MessageComposerInput placeholder='Text' />
<MessageComposerToolbar>
<MessageToolbarActions />
<MessageComposerToolbarSubmit>
<Button small>Preview</Button>
<Button primary small>
Send
</Button>
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
),
};
export const WithFiles: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageComposer>
<MessageComposerInput placeholder='Text' />
<MessageComposerFileGroup>
<MessageComposerFile
fileTitle='antique-pocket-clock-500x500.zip'
fileSubtitle='58.33 KB - application/zip'
fileFormat='zip'
actionIcon={<IconButton aria-label='Close' icon='cross' mini />}
onClick={action('click')}
/>
<MessageComposerFile
disabled
fileTitle='file.png'
fileSubtitle='2 MB'
fileFormat='png'
actionIcon={<IconButton aria-label='Close' icon='cross' mini />}
onClick={action('click')}
/>
<MessageComposerFileError
fileTitle='file.png'
fileFormat='png'
error={new Error('Something went wrong')}
actionIcon={<IconButton aria-label='Close' icon='cross' mini />}
onClick={action('click')}
/>
</MessageComposerFileGroup>
<MessageComposerToolbar>
<MessageToolbarActions />
<MessageComposerToolbarSubmit>
<Button small>Preview</Button>
<Button primary small>
Send
</Button>
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
),
};
export const Loading: StoryObj<typeof MessageComposer> = {
render: () => <MessageComposerSkeleton />,
};

View File

@@ -0,0 +1,28 @@
import { Box } from '@rocket.chat/fuselage';
import type { ElementType, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
export type MessageComposerProps = Omit<HTMLAttributes<HTMLElement>, 'is'> & {
children: ReactNode;
is?: ElementType<any>;
variant?: 'default' | 'error' | 'editing';
};
const MessageComposer = forwardRef<HTMLElement, MessageComposerProps>(function MessageComposer({ variant, ...props }, ref) {
return (
<Box
rcx-input-box__wrapper
marginBlockStart={2}
backgroundColor={variant === 'editing' ? 'status-background-warning-2' : undefined}
ref={ref}
role='group'
display='flex'
flexDirection='column'
overflow='hidden'
padding={0}
{...props}
/>
);
});
export default MessageComposer;

View File

@@ -0,0 +1,6 @@
import { IconButton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const MessageComposerAction = (props: ComponentProps<typeof IconButton>) => <IconButton small {...props} />;
export default MessageComposerAction;

View File

@@ -0,0 +1,8 @@
import { Divider } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const MessageComposerActionsDivider = ({ height = 'x20', ...props }: ComponentProps<typeof Divider>) => (
<Divider vertical marginInline={4} borderColor='light' marginBlock={0} backgroundColor='selected' height={height} {...props} />
);
export default MessageComposerActionsDivider;

View File

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

View File

@@ -0,0 +1,111 @@
import { css } from '@rocket.chat/css-in-js';
import { Avatar, Box, Palette, Skeleton } from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { FilePreviewIcon } from '@rocket.chat/ui-client';
import type { ReactNode, KeyboardEvent, MouseEvent, AllHTMLAttributes } from 'react';
import { useMemo } from 'react';
export type MessageComposerFileProps = {
fileTitle: string;
fileSubtitle: string;
fileFormat: string;
showPreview?: boolean;
previewUrl?: string;
alt?: string;
actionIcon: ReactNode;
error?: boolean;
disabled?: boolean;
onClick: () => void;
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const MessageComposerFile = ({
fileTitle,
fileSubtitle,
fileFormat,
showPreview,
previewUrl,
alt = '',
actionIcon,
error,
disabled,
onClick,
className,
...props
}: MessageComposerFileProps) => {
const previewWrapperStyle = css`
background-color: ${Palette.surface['surface-tint']};
cursor: ${error || disabled ? 'not-allowed' : 'pointer'};
&:hover {
background-color: ${!error && !disabled ? Palette.surface['surface-hover'] : Palette.surface['surface-tint']};
}
`;
const handleClick = (e: MouseEvent<Element> | KeyboardEvent<Element>) => {
e.stopPropagation();
if (!error && !disabled) {
onClick();
}
};
const buttonProps = useButtonPattern(handleClick);
const subtitleColor = useMemo(() => {
if (error) {
return 'danger';
}
if (disabled) {
return 'disabled';
}
return 'hint';
}, [disabled, error]);
return (
<Box
rcx-input-box__wrapper={!error && !disabled}
readOnly={error}
className={[previewWrapperStyle, className].filter(Boolean)}
role='group'
aria-label={fileTitle}
display='flex'
flexShrink={0}
flexGrow={0}
padding={4}
borderRadius={4}
borderWidth={1}
borderColor={error ? 'error' : 'extra-light'}
alignItems='center'
position='relative'
height='x58'
width='x234'
marginInlineEnd={8}
onClick={handleClick}
{...props}
>
{showPreview ? (
<Box minWidth='x48'>
{previewUrl ? (
<Avatar objectFit='cover' url={previewUrl} size='x48' alt={alt} />
) : (
<Skeleton variant='rect' width='x48' height='x48' />
)}
</Box>
) : (
<FilePreviewIcon format={fileFormat} />
)}
<Box flexGrow={1} withTruncatedText marginInlineStart={4} display='flex' flexDirection='column'>
<Box {...buttonProps} fontScale='p2' color={disabled ? 'disabled' : 'info'} withTruncatedText>
{fileTitle}
</Box>
<Box fontScale='c1' color={subtitleColor} textTransform={!error ? 'uppercase' : 'none'} withTruncatedText>
{fileSubtitle}
</Box>
</Box>
{!disabled && <Box alignSelf='start'>{actionIcon}</Box>}
</Box>
);
};
export default MessageComposerFile;

View File

@@ -0,0 +1,30 @@
import type { AllHTMLAttributes, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import MessageComposerFile from './MessageComposerFile';
export type MessageComposerFileErrorProps = {
fileTitle: string;
fileFormat: string;
error: Error;
actionIcon: ReactNode;
onClick: () => void;
} & AllHTMLAttributes<HTMLButtonElement>;
const MessageComposerFileError = ({ fileTitle, error, actionIcon, onClick, ...props }: MessageComposerFileErrorProps) => {
const { t } = useTranslation();
return (
<MessageComposerFile
error={true}
fileTitle={fileTitle}
fileSubtitle={t('Upload_failed')}
actionIcon={actionIcon}
title={error.message}
onClick={onClick}
{...props}
/>
);
};
export default MessageComposerFileError;

View File

@@ -0,0 +1,23 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const MessageComposerFileGroup = ({ children, style, ...props }: ComponentProps<typeof Box>) => {
return (
<Box
role='group'
display='flex'
width='100%'
flexDirection='row'
paddingInline={8}
paddingBlockEnd={8}
paddingBlockStart={2}
overflowX='auto'
style={{ whiteSpace: 'nowrap', ...style }}
{...props}
>
{children}
</Box>
);
};
export default MessageComposerFileGroup;

View File

@@ -0,0 +1,41 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
const MessageComposerFileLoader = ({ className, ...props }: Omit<AllHTMLAttributes<HTMLOrSVGElement>, 'is'>) => {
const customCSS = css`
animation: spin-animation 0.8s linear infinite;
@keyframes spin-animation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`;
return (
<Box
className={[customCSS, className].filter(Boolean)}
is='svg'
size='x24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<circle cx='11.9998' cy='12' r='8' stroke={Palette.stroke['stroke-extra-light'].toString()} strokeWidth='2' />
<path
d='M20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12'
stroke={Palette.stroke['stroke-highlight'].toString()}
strokeWidth='2'
strokeLinecap='round'
/>
</Box>
);
};
export default MessageComposerFileLoader;

View File

@@ -0,0 +1,4 @@
export { default as MessageComposerFile } from './MessageComposerFile';
export { default as MessageComposerFileError } from './MessageComposerFileError';
export { default as MessageComposerFileLoader } from './MessageComposerFileLoader';
export { default as MessageComposerFileGroup } from './MessageComposerFileGroup';

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './MessageComposerHint.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,31 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MessageComposerHint } from '.';
export default {
component: MessageComposerHint,
} as Meta<typeof MessageComposerHint>;
export const HintWithIconAndHelperText: StoryObj<typeof MessageComposerHint> = {
render: () => {
const helperText = (
<>
<strong>esc</strong> to cancel · <strong>enter</strong> to save
</>
);
return (
<MessageComposerHint icon='pencil' helperText={helperText}>
Editing message
</MessageComposerHint>
);
},
};
export const HintWithIcon: StoryObj<typeof MessageComposerHint> = {
render: () => <MessageComposerHint icon='eye'>This room is read only</MessageComposerHint>,
};
export const HintWithText: StoryObj<typeof MessageComposerHint> = {
render: () => <MessageComposerHint>You&apos;re sending an unencrypted message</MessageComposerHint>,
};

View File

@@ -0,0 +1,21 @@
import type { IconProps } from '@rocket.chat/fuselage';
import { Box, Icon, Tag } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type MessageComposerHintProps = {
children: string;
icon?: IconProps['name'];
helperText?: ReactNode;
};
const MessageComposerHint = ({ icon, children, helperText }: MessageComposerHintProps) => (
<Box paddingBlockStart={0} paddingBlockEnd={4} display='flex' flexDirection='row' justifyContent='space-between' alignItems='center'>
<Tag icon={icon ? <Icon marginInlineEnd={4} name={icon} size='x12' /> : undefined}>{children}</Tag>
{helperText && (
<Box fontScale='c1' color='hint'>
{helperText}
</Box>
)}
</Box>
);
export default MessageComposerHint;

View File

@@ -0,0 +1,11 @@
import { Icon } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { HTMLAttributes } from 'react';
export type MessageComposerIconProps = { name: Keys } & Omit<HTMLAttributes<HTMLElement>, 'is'>;
const MessageComposerIcon = ({ name, ...props }: MessageComposerIconProps) => (
<Icon name={name} size='x20' marginInlineEnd={4} {...props} />
);
export default MessageComposerIcon;

View File

@@ -0,0 +1,36 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Palette } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
const messageComposerInputStyle = css`
resize: none;
&::placeholder {
color: ${Palette.text['font-annotation']};
}
`;
export type MessageComposerInputProps = ComponentProps<typeof Box>;
const MessageComposerInput = forwardRef<HTMLTextAreaElement, MessageComposerInputProps>(function MessageComposerInput(props, ref) {
return (
<Box
className={[messageComposerInputStyle, 'rc-message-box__textarea js-input-message']}
color='default'
width='full'
minHeight={52}
maxHeight={155}
rows={1}
fontScale='p2'
ref={ref}
paddingInline={12}
paddingBlock={16}
borderWidth={0}
is='textarea'
{...props}
/>
);
});
export default MessageComposerInput;

View File

@@ -0,0 +1,124 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MessageComposerInputExpandable from './MessageComposerInputExpandable';
test('should show expand button when dimensions.blockSize > 100', () => {
render(
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 120,
}}
placeholder='Type a message...'
/>,
{ wrapper: mockAppRoot().build() },
);
const expandButton = screen.getByRole('button');
expect(expandButton).toBeInTheDocument();
expect(expandButton).toHaveAttribute('title', 'Expand');
});
test('should not show expand button when dimensions.blockSize <= 100', () => {
render(
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 80,
}}
placeholder='Type a message...'
/>,
{ wrapper: mockAppRoot().build() },
);
const expandButton = screen.queryByRole('button');
expect(expandButton).not.toBeInTheDocument();
});
test('should expand input when expand button is clicked', async () => {
render(
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 120,
}}
placeholder='Type a message...'
/>,
{ wrapper: mockAppRoot().build() },
);
const expandButton = screen.getByRole('button');
const textarea = screen.getByRole('textbox');
// Initially not expanded
expect(textarea).not.toHaveStyle({ height: '500px' });
// Click to expand
await userEvent.click(expandButton);
// Should be expanded now
expect(textarea).toHaveStyle({ height: '500px' });
expect(textarea).toHaveStyle({ maxHeight: '50vh' });
expect(expandButton).toHaveAttribute('title', 'Collapse');
});
test('should collapse input when collapse button is clicked', async () => {
render(
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 120,
}}
placeholder='Type a message...'
/>,
{ wrapper: mockAppRoot().build() },
);
const expandButton = screen.getByRole('button');
const textarea = screen.getByRole('textbox');
// Expand first
await userEvent.click(expandButton);
expect(textarea).toHaveStyle({ height: '500px' });
// Click to collapse
await userEvent.click(expandButton);
// Should be collapsed now
expect(textarea).not.toHaveStyle({ height: '500px' });
expect(textarea).not.toHaveStyle({ maxHeight: '50vh' });
expect(expandButton).toHaveAttribute('title', 'Expand');
});
test('should auto-collapse when input is cleared', async () => {
render(
<MessageComposerInputExpandable
dimensions={{
inlineSize: 400,
blockSize: 120,
}}
placeholder='Type a message...'
/>,
{ wrapper: mockAppRoot().build() },
);
const expandButton = screen.getByRole('button');
const textarea = screen.getByRole('textbox');
// Expand first
await userEvent.click(expandButton);
expect(textarea).toHaveStyle({ height: '500px' });
// Type some text
await userEvent.type(textarea, 'Some text');
expect(textarea).toHaveStyle({ height: '500px' });
// Clear the text
await userEvent.clear(textarea);
// Should auto-collapse
expect(textarea).not.toHaveStyle({ height: '500px' });
expect(textarea).not.toHaveStyle({ maxHeight: '50vh' });
});

View File

@@ -0,0 +1,56 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, IconButton } from '@rocket.chat/fuselage';
import { useState, forwardRef } from 'react';
import type { ChangeEvent, ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import MessageComposerInput from './MessageComposerInput';
export type ExpandComposerButtonProps = ComponentProps<typeof Box> & {
dimensions: Readonly<{
inlineSize: number;
blockSize: number;
}>;
};
const MessageComposerInputExpandable = forwardRef<HTMLTextAreaElement, ExpandComposerButtonProps>(
({ dimensions, onChange, ...props }, ref) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
if (event.target.value.length === 0) {
setExpanded(false);
}
onChange?.(event);
};
return (
<>
{dimensions.blockSize > 100 && (
<Box
position='absolute'
padding={8}
className={css`
top: 0;
right: 0;
`}
>
<IconButton
small
icon={expanded ? 'arrow-collapse' : 'arrow-expand'}
title={expanded ? t('Collapse') : t('Expand')}
onClick={() => setExpanded(!expanded)}
/>
</Box>
)}
<MessageComposerInput ref={ref} onChange={handleChange} {...(!!expanded && { height: 500, maxHeight: '50vh' })} {...props} />
</>
);
},
);
MessageComposerInputExpandable.displayName = 'MessageComposerInputExpandable';
export default MessageComposerInputExpandable;

View File

@@ -0,0 +1,14 @@
import { Skeleton, Box } from '@rocket.chat/fuselage';
import { MessageComposer, MessageComposerToolbar } from '.';
const MessageComposerSkeleton = () => (
<MessageComposer>
<Box width='100%' display='flex' alignItems='center' height='x52' paddingInline={12}>
<Skeleton width='100%' height={36} />
</Box>
<MessageComposerToolbar height={36} />
</MessageComposer>
);
export default MessageComposerSkeleton;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const MessageComposerToolbar = (props: ComponentProps<typeof Box>) => (
<Box backgroundColor='surface-neutral' padding={4} display='flex' justifyContent='space-between' width='full' {...props} />
);
export default MessageComposerToolbar;

View File

@@ -0,0 +1,16 @@
import { useToolbar } from '@react-aria/toolbar';
import { ButtonGroup } from '@rocket.chat/fuselage';
import { useRef, type ComponentProps } from 'react';
const MessageComposerToolbarActions = (props: ComponentProps<typeof ButtonGroup>) => {
const ref = useRef(null);
const { toolbarProps } = useToolbar(props, ref);
return (
<ButtonGroup role='toolbar' small ref={ref} {...toolbarProps}>
{props.children}
</ButtonGroup>
);
};
export default MessageComposerToolbarActions;

View File

@@ -0,0 +1,6 @@
import { ButtonGroup } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
const MessageComposerToolbarSubmit = (props: ComponentProps<typeof ButtonGroup>) => <ButtonGroup small {...props} />;
export default MessageComposerToolbarSubmit;

View File

@@ -0,0 +1,85 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders HintWithIcon without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-r3aouy"
>
<span
class="rcx-box rcx-box--full rcx-tag "
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-eye rcx-icon rcx-css-nbjs1j"
>
</i>
<span
class="rcx-tag__inner"
>
This room is read only
</span>
</span>
</div>
</div>
</body>
`;
exports[`renders HintWithIconAndHelperText without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-r3aouy"
>
<span
class="rcx-box rcx-box--full rcx-tag "
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-pencil rcx-icon rcx-css-nbjs1j"
>
⦏
</i>
<span
class="rcx-tag__inner"
>
Editing message
</span>
</span>
<div
class="rcx-box rcx-box--full rcx-css-1dp5kdc"
>
<strong>
esc
</strong>
to cancel ·
<strong>
enter
</strong>
to save
</div>
</div>
</div>
</body>
`;
exports[`renders HintWithText without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-r3aouy"
>
<span
class="rcx-box rcx-box--full rcx-tag "
>
<span
class="rcx-tag__inner"
>
You're sending an unencrypted message
</span>
</span>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,29 @@
import MessageComposer from './MessageComposer';
import MessageComposerAction from './MessageComposerAction';
import MessageComposerActionsDivider from './MessageComposerActionsDivider';
import MessageComposerButton from './MessageComposerButton';
import MessageComposerHint from './MessageComposerHint';
import MessageComposerIcon from './MessageComposerIcon';
import MessageComposerInput from './MessageComposerInput';
import MessageComposerInputExpandable from './MessageComposerInputExpandable';
import MessageComposerSkeleton from './MessageComposerSkeleton';
import MessageComposerToolbar from './MessageComposerToolbar';
import MessageComposerToolbarActions from './MessageComposerToolbarActions';
import MessageComposerToolbarSubmit from './MessageComposerToolbarSubmit';
export {
MessageComposer,
MessageComposerAction,
MessageComposerActionsDivider,
MessageComposerInput,
MessageComposerInputExpandable,
MessageComposerToolbar,
MessageComposerToolbarActions,
MessageComposerToolbarSubmit,
MessageComposerSkeleton,
MessageComposerIcon,
MessageComposerHint,
MessageComposerButton,
};
export { MessageComposerFile, MessageComposerFileError, MessageComposerFileLoader, MessageComposerFileGroup } from './MessageComposerFile';

View File

@@ -0,0 +1,19 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './MessageFooterCallout.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,59 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MessageFooterCallout, MessageFooterCalloutAction } from '.';
import MessageFooterCalloutContent from './MessageFooterCalloutContent';
import MessageFooterCalloutDivider from './MessageFooterCalloutDivider';
import MessageComposer from '../MessageComposer/MessageComposer';
import MessageComposerIcon from '../MessageComposer/MessageComposerIcon';
export default {
component: MessageComposer,
} satisfies Meta<typeof MessageComposer>;
export const MessageComposerBlocked: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageFooterCallout>
<MessageComposerIcon name='burger' />
Feedback text
</MessageFooterCallout>
),
};
export const MessageComposerBlockedLargeText: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageFooterCallout>
<MessageComposerIcon name='burger' />
<MessageFooterCalloutContent>
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text
Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback
text Feedback text Feedback text Feedback text
</MessageFooterCalloutContent>
<MessageFooterCalloutAction onClick={(): void => undefined}>Button</MessageFooterCalloutAction>
</MessageFooterCallout>
),
};
export const MessageComposerBlockedLargeTextDashed: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageFooterCallout dashed>
<MessageComposerIcon name='burger' />
<MessageFooterCalloutContent>
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text
Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback
text Feedback text Feedback text Feedback text
</MessageFooterCalloutContent>
<MessageFooterCalloutAction onClick={(): void => undefined}>Button</MessageFooterCalloutAction>
</MessageFooterCallout>
),
};
export const _MessageFooterCalloutAction: StoryObj<typeof MessageComposer> = {
render: () => (
<MessageFooterCallout>
Feedback text <MessageFooterCalloutDivider />
<MessageFooterCalloutAction onClick={(): void => undefined}>Button</MessageFooterCalloutAction>
</MessageFooterCallout>
),
};

View File

@@ -0,0 +1,36 @@
import { Box } from '@rocket.chat/fuselage';
import type { ElementType, HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
const MessageFooterCallout = forwardRef<
HTMLElement,
Omit<HTMLAttributes<HTMLElement>, 'is'> & {
children: ReactNode;
is?: ElementType<any>;
variant?: 'default' | 'error';
dashed?: boolean;
}
>(function MessageFooterCallout({ dashed, ...props }, ref) {
return (
<Box
ref={ref}
{...(dashed && {
borderStyle: 'dashed',
})}
display='flex'
borderWidth={2}
borderColor='light'
borderRadius='x4'
padding={8}
marginBlockEnd={24}
backgroundColor='surface-tint'
alignItems='center'
minHeight='x48'
justifyContent='center'
color='default'
{...props}
/>
);
});
export default MessageFooterCallout;

View File

@@ -0,0 +1,13 @@
import { Button } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
export type MessageFooterCalloutActionProps = ComponentProps<typeof Button>;
const MessageFooterCalloutAction = forwardRef<HTMLButtonElement, MessageFooterCalloutActionProps>(
function MessageFooterCalloutAction(props, ref) {
return <Button marginInline={4} ref={ref} primary small flexShrink={0} {...props} />;
},
);
export default MessageFooterCalloutAction;

View File

@@ -0,0 +1,13 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
export type MessageFooterCalloutContentProps = ComponentProps<typeof Box>;
const MessageFooterCalloutContent = forwardRef<HTMLDivElement, MessageFooterCalloutContentProps>(
function MessageFooterCalloutContent(props, ref) {
return <Box marginInline={4} ref={ref} flexWrap='wrap' textAlign='center' color='default' flexGrow={1} flexShrink={1} {...props} />;
},
);
export default MessageFooterCalloutContent;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import { forwardRef } from 'react';
const MessageFooterCalloutDivider = forwardRef<HTMLButtonElement>(function MessageFooterCalloutDivider(props, ref) {
return <Box is='hr' ref={ref} borderInlineStart='1px solid' marginInline={4} flexShrink={0} {...props} />;
});
export default MessageFooterCalloutDivider;

View File

@@ -0,0 +1,108 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders _MessageFooterCalloutAction without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-l452z"
>
Feedback text
<hr
class="rcx-box rcx-box--full rcx-css-vi5s2c"
/>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button--primary rcx-button rcx-css-1dy74d9"
type="button"
>
<span
class="rcx-button--content"
>
Button
</span>
</button>
</div>
</div>
</body>
`;
exports[`renders MessageComposerBlocked without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-l452z"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-burger rcx-icon rcx-css-1c32rho"
>
</i>
Feedback text
</div>
</div>
</body>
`;
exports[`renders MessageComposerBlockedLargeText without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-l452z"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-burger rcx-icon rcx-css-1c32rho"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-vfwfo1"
>
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text
</div>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button--primary rcx-button rcx-css-1dy74d9"
type="button"
>
<span
class="rcx-button--content"
>
Button
</span>
</button>
</div>
</div>
</body>
`;
exports[`renders MessageComposerBlockedLargeTextDashed without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-1pl9ovb"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-burger rcx-icon rcx-css-1c32rho"
>
</i>
<div
class="rcx-box rcx-box--full rcx-css-vfwfo1"
>
Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text text Feedback text Feedback text Feedback text Feedback text Feedback text
</div>
<button
class="rcx-box rcx-box--full rcx-button--small rcx-button--primary rcx-button rcx-css-1dy74d9"
type="button"
>
<span
class="rcx-button--content"
>
Button
</span>
</button>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,6 @@
import MessageFooterCallout from './MessageFooterCallout';
import MessageFooterCalloutAction from './MessageFooterCalloutAction';
import MessageFooterCalloutContent from './MessageFooterCalloutContent';
import MessageFooterCalloutDivider from './MessageFooterCalloutDivider';
export { MessageFooterCallout, MessageFooterCalloutAction, MessageFooterCalloutDivider, MessageFooterCalloutContent };

View File

@@ -0,0 +1,2 @@
export * from './MessageComposer';
export * from './MessageFooterCallout';