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,21 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import { MessageFooterCallout } from '@rocket.chat/ui-composer';
import { Trans } from 'react-i18next';
const ComposerAirGappedRestricted = () => {
return (
<MessageFooterCallout color='default'>
<Icon name='warning' size={20} marginInlineEnd={4} />
<span>
<Trans i18nKey='Composer_readonly_airgapped'>
<Box is='span' fontWeight={600}>
Workspace in read-only mode.
</Box>
Admins can restore full functionality by connecting it to internet or upgrading to a premium plan.
</Trans>
</span>
</MessageFooterCallout>
);
};
export default ComposerAirGappedRestricted;

View File

@@ -0,0 +1,55 @@
import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage';
import {
useSessionDispatch,
useSetting,
useTranslation,
useLoginWithToken,
useToastMessageDispatch,
useMethod,
} from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
const ComposerAnonymous = () => {
const t = useTranslation();
const dispatch = useToastMessageDispatch();
const isAnonymousWriteEnabled = useSetting('Accounts_AllowAnonymousWrite');
const loginWithToken = useLoginWithToken();
const anonymousUser = useMethod('registerUser');
const setForceLogin = useSessionDispatch('forceLogin');
const registerAnonymous = useMutation({
mutationFn: async (...params: Parameters<typeof anonymousUser>) => {
const result = await anonymousUser(...params);
if (typeof result !== 'string' && result.token) {
await loginWithToken(result.token);
}
return result;
},
onError: (error) => {
dispatch({ type: 'error', message: error });
},
});
const joinAnonymous = () => {
registerAnonymous.mutate({ email: null });
};
return (
<Box marginBlock={16}>
<ButtonGroup>
<Button small primary onClick={() => setForceLogin(true)}>
{t('Sign_in_to_start_talking')}
</Button>
{isAnonymousWriteEnabled && (
<Button small secondary onClick={() => joinAnonymous()}>
{t('Or_talk_as_anonymous')}
</Button>
)}
</ButtonGroup>
</Box>
);
};
export default ComposerAnonymous;

View File

@@ -0,0 +1,14 @@
import { MessageFooterCallout, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
const ComposerReadOnly = () => {
const { t } = useTranslation();
return (
<MessageFooterCallout>
<MessageFooterCalloutContent>{t('Room_archived')}</MessageFooterCalloutContent>
</MessageFooterCallout>
);
};
export default ComposerReadOnly;

View File

@@ -0,0 +1,9 @@
import { MessageFooterCallout } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
const ComposerBlocked = () => {
const { t } = useTranslation();
return <MessageFooterCallout>{t('room_is_blocked')}</MessageFooterCallout>;
};
export default ComposerBlocked;

View File

@@ -0,0 +1,124 @@
import { Box, Option, OptionSkeleton, Tile } from '@rocket.chat/fuselage';
import { useContentBoxSize } from '@rocket.chat/fuselage-hooks';
import { CustomScrollbars } from '@rocket.chat/ui-client';
import type { UseQueryResult } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, memo, useMemo, useRef, useId } from 'react';
import { useTranslation } from 'react-i18next';
export type ComposerBoxPopupProps<
T extends {
_id: string;
sort?: number;
disabled?: boolean;
},
> = {
title?: string;
focused?: T;
items: UseQueryResult<T[]>[];
select: (item: T) => void;
renderItem?: ({ item }: { item: T }) => ReactNode;
};
function ComposerBoxPopup<
T extends {
_id: string;
sort?: number;
disabled?: boolean;
},
>({ title, items, focused, select, renderItem = ({ item }: { item: T }) => <>{JSON.stringify(item)}</> }: ComposerBoxPopupProps<T>) {
const { t } = useTranslation();
const id = useId();
const composerBoxPopupRef = useRef<HTMLElement>(null);
const popupSizes = useContentBoxSize(composerBoxPopupRef);
const variant = popupSizes && popupSizes.inlineSize < 480 ? 'small' : 'large';
const getOptionTitle = <T extends { _id: string; sort?: number; outside?: boolean; suggestion?: boolean; disabled?: boolean }>(
item: T,
) => {
if (variant !== 'small') {
return undefined;
}
if (item.outside) {
return t('Not_in_channel');
}
if (item.suggestion) {
return t('Suggestion_from_recent_messages');
}
if (item.disabled) {
return t('Unavailable_in_encrypted_channels');
}
};
const itemsFlat = useMemo(
() =>
items
.flatMap((item) => {
if (item.isSuccess) {
return item.data;
}
return [];
})
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0)),
[items],
);
const isLoading = items.some((item) => item.isLoading && item.fetchStatus !== 'idle');
useEffect(() => {
if (focused) {
const element = document.getElementById(`popup-item-${focused._id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [focused]);
return (
<Box position='relative'>
<Tile
ref={composerBoxPopupRef}
padding={0}
role='menu'
marginBlockEnd={8}
overflow='hidden'
aria-labelledby={id}
name='ComposerBoxPopup'
>
{title && (
<Box backgroundColor='tint' paddingInline={16} paddingBlock={8} id={id}>
{title}
</Box>
)}
<CustomScrollbars>
<Box paddingBlock={8} maxHeight='x320'>
{!isLoading && itemsFlat.length === 0 && <Option>{t('No_results_found')}</Option>}
{isLoading && <OptionSkeleton />}
{itemsFlat.map((item, index) => {
return (
<Option
title={getOptionTitle(item)}
onClick={() => select(item)}
selected={item === focused}
key={index}
id={`popup-item-${item._id}`}
tabIndex={item === focused ? 0 : -1}
aria-selected={item === focused}
disabled={item.disabled}
>
{renderItem({ item: { ...item, variant } })}
</Option>
);
})}
</Box>
</CustomScrollbars>
</Tile>
</Box>
);
}
export default memo(ComposerBoxPopup);

View File

@@ -0,0 +1,20 @@
import { OptionColumn, OptionContent } from '@rocket.chat/fuselage';
export type ComposerBoxPopupCannedResponseProps = {
_id: string;
text: string;
shortcut: string;
};
function ComposerBoxPopupCannedResponse({ shortcut, text }: ComposerBoxPopupCannedResponseProps) {
return (
<>
<OptionColumn>
<strong>{shortcut}</strong>
</OptionColumn>
<OptionContent>{text}</OptionContent>
</>
);
}
export default ComposerBoxPopupCannedResponse;

View File

@@ -0,0 +1,20 @@
import { OptionColumn, OptionContent } from '@rocket.chat/fuselage';
import Emoji from '../../../components/Emoji';
export type ComposerBoxPopupEmojiProps = {
_id: string;
};
function ComposerBoxPopupEmoji({ _id }: ComposerBoxPopupEmojiProps) {
return (
<>
<OptionColumn>
<Emoji emojiHandle={_id} />
</OptionColumn>
<OptionContent>{_id}</OptionContent>
</>
);
}
export default ComposerBoxPopupEmoji;

View File

@@ -0,0 +1,163 @@
import { Box, Skeleton, Tile, Option } from '@rocket.chat/fuselage';
import { Random } from '@rocket.chat/random';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { ForwardedRef, ReactNode } from 'react';
import { forwardRef, useEffect, useId, useImperativeHandle } from 'react';
import type { ComposerBoxPopupProps } from './ComposerBoxPopup';
import { useChat } from '../contexts/ChatContext';
type ComposerBoxPopupPreviewItem = { _id: string; type: 'image' | 'video' | 'audio' | 'text' | 'other'; value: string; sort?: number };
export type ComposerBoxPopupPreviewProps = ComposerBoxPopupProps<ComposerBoxPopupPreviewItem> & {
title?: ReactNode;
rid: string;
tmid?: string;
suspended: boolean;
};
const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview(
{ focused, items, title, rid, tmid, select, suspended }: ComposerBoxPopupPreviewProps,
ref: ForwardedRef<
| {
getFilter?: () => unknown;
select?: (s: ComposerBoxPopupPreviewItem) => void;
}
| undefined
>,
) {
const id = useId();
const chat = useChat();
const executeSlashCommandPreviewEndpoint = useEndpoint('POST', '/v1/commands.preview');
useImperativeHandle(
ref,
() => ({
getFilter: () => {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
if (!value) {
throw new Error('No value');
}
const matches = value.match(/(\/[\w\d\S]+ )([^]*)$/);
if (!matches) {
throw new Error('No matches');
}
const cmd = matches[1].replace('/', '').trim().toLowerCase();
const params = matches[2];
return { cmd, params, msg: { rid, tmid } };
},
...(!suspended && {
select: (item) => {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
if (!value) {
throw new Error('No value');
}
const matches = value.match(/(\/[\w\d\S]+ )([^]*)$/);
if (!matches) {
throw new Error('No matches');
}
const cmd = matches[1].replace('/', '').trim().toLowerCase();
const params = matches[2];
void executeSlashCommandPreviewEndpoint({
command: cmd,
params,
roomId: rid,
...(tmid && { tmid }),
triggerId: Random.id(),
previewItem: { id: item._id, type: item.type, value: item.value },
});
chat?.composer?.setText('');
},
}),
}),
[chat?.composer, executeSlashCommandPreviewEndpoint, rid, tmid, suspended],
);
const itemsFlat = items
.flatMap((item) => {
if (item.isSuccess) {
return item.data;
}
return [];
})
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
const isLoading = items.some((item) => item.isLoading && item.fetchStatus !== 'idle');
useEffect(() => {
if (focused) {
const element = document.getElementById(`popup-item-${focused._id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [focused]);
if (suspended) {
return null;
}
return (
<Box position='relative'>
<Tile padding={0} role='menu' marginBlockEnd={8} overflow='hidden' aria-labelledby={id}>
{title && (
<Box backgroundColor='tint' paddingInline={16} paddingBlock={8} id={id}>
{title}
</Box>
)}
<Box display='flex' padding={8}>
<Box role='listbox' display='flex' overflow='auto' fontSize={0} width={0} flexGrow={1} aria-busy={isLoading}>
{isLoading &&
Array(5)
.fill(5)
.map((_, index) => <Skeleton variant='rect' height='100px' width='120px' margin={2} key={index} />)}
{!isLoading &&
itemsFlat.map((item) => (
<Box
onClick={() => select(item)}
role='option'
className={['popup-item', item === focused && 'selected'].filter(Boolean).join(' ')}
id={`popup-item-${item._id}`}
key={item._id}
backgroundColor={item === focused ? 'selected' : undefined}
borderColor={item === focused ? 'highlight' : 'transparent'}
tabIndex={item === focused ? 0 : -1}
aria-selected={item === focused}
margin={2}
borderWidth='default'
borderRadius='x4'
>
{item.type === 'image' && <img src={item.value} alt={item._id} />}
{item.type === 'audio' && (
<audio controls>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the audio element.
</audio>
)}
{item.type === 'video' && (
<video controls className='inline-video'>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the video element.
</video>
)}
{item.type === 'text' && <Option>{item.value}</Option>}
{item.type === 'other' && <code>{item.value}</code>}
</Box>
))}
</Box>
</Box>
</Tile>
</Box>
);
});
export default ComposerBoxPopupPreview;

View File

@@ -0,0 +1,21 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { OptionColumn, OptionContent } from '@rocket.chat/fuselage';
import { RoomIcon } from '../../../components/RoomIcon';
export type ComposerBoxPopupRoomProps = Pick<IRoom, 't' | 'name' | 'fname' | '_id' | 'prid' | 'teamMain' | 'u'>;
function ComposerBoxPopupRoom({ fname, name, ...props }: ComposerBoxPopupRoomProps) {
return (
<>
<OptionColumn>
<RoomIcon room={props} />
</OptionColumn>
<OptionContent>
<strong>{fname ?? name}</strong>
</OptionContent>
</>
);
}
export default ComposerBoxPopupRoom;

View File

@@ -0,0 +1,27 @@
import { OptionContent, OptionDescription } from '@rocket.chat/fuselage';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
export type ComposerBoxPopupSlashCommandProps = {
_id: string;
description?: string;
params?: string;
disabled?: boolean;
};
const slashCommandDescriptionStyle: CSSProperties = { textAlign: 'right' };
function ComposerBoxPopupSlashCommand({ _id, description, params, disabled }: ComposerBoxPopupSlashCommandProps) {
const { t } = useTranslation();
return (
<>
<OptionContent>
{_id} <OptionDescription>{params}</OptionDescription>
</OptionContent>
<OptionContent style={slashCommandDescriptionStyle}>{disabled ? t('Unavailable_in_encrypted_channels') : description}</OptionContent>
</>
);
}
export default ComposerBoxPopupSlashCommand;

View File

@@ -0,0 +1,66 @@
import { OptionAvatar, OptionColumn, OptionContent, OptionInput } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { getUserDisplayNames } from '../../../../lib/getUserDisplayNames';
import ReactiveUserStatus from '../../../components/UserStatus/ReactiveUserStatus';
export type ComposerBoxPopupUserProps = {
_id: string;
system?: boolean;
outside?: boolean;
suggestion?: boolean;
username: string;
name?: string;
nickname?: string;
status?: string;
sort?: number;
variant?: 'small' | 'large';
};
function ComposerBoxPopupUser({ _id, system, username, name, nickname, outside, suggestion, variant }: ComposerBoxPopupUserProps) {
const { t } = useTranslation();
const useRealName = useSetting('UI_Use_Real_Name', false);
const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName);
return (
<>
{!system && (
<>
<OptionAvatar>
<UserAvatar size='x28' username={username} />
</OptionAvatar>
<OptionColumn>
<ReactiveUserStatus uid={_id} />
</OptionColumn>
<OptionContent>
<strong>{nameOrUsername}</strong> {displayUsername && `@${displayUsername}`}
{nickname && <span className='popup-user-nickname'> ({nickname})</span>}
</OptionContent>
</>
)}
{system && (
<OptionContent>
<strong>@{username}</strong> {name}
</OptionContent>
)}
{outside && variant === 'large' && (
<OptionColumn>
<OptionInput>{t('Not_in_channel')}</OptionInput>
</OptionColumn>
)}
{suggestion && variant === 'large' && (
<OptionColumn>
<OptionInput>{t('Suggestion_from_recent_messages')}</OptionInput>
</OptionColumn>
)}
</>
);
}
export default ComposerBoxPopupUser;

View File

@@ -0,0 +1,87 @@
import { isOmnichannelRoom, isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import ComposerAirGappedRestricted from './ComposerAirGappedRestricted';
import ComposerAnonymous from './ComposerAnonymous';
import ComposerArchived from './ComposerArchived';
import ComposerBlocked from './ComposerBlocked';
import ComposerFederation from './ComposerFederation';
import ComposerJoinWithPassword from './ComposerJoinWithPassword';
import type { ComposerMessageProps } from './ComposerMessage';
import ComposerMessage from './ComposerMessage';
import ComposerOmnichannel from './ComposerOmnichannel';
import ComposerReadOnly from './ComposerReadOnly';
import ComposerSelectMessages from './ComposerSelectMessages';
import { useRoom } from '../contexts/RoomContext';
import { useMessageComposerIsAnonymous } from './hooks/useMessageComposerIsAnonymous';
import { useMessageComposerIsArchived } from './hooks/useMessageComposerIsArchived';
import { useMessageComposerIsBlocked } from './hooks/useMessageComposerIsBlocked';
import { useMessageComposerIsReadOnly } from './hooks/useMessageComposerIsReadOnly';
import { useAirGappedRestriction } from '../../../hooks/useAirGappedRestriction';
import { useIsSelecting } from '../MessageList/contexts/SelectedMessagesContext';
const ComposerContainer = ({ children, ...props }: ComposerMessageProps) => {
const room = useRoom();
const canJoinWithoutCode = usePermission('join-without-join-code');
const mustJoinWithCode = !props.subscription && room.joinCodeRequired && !canJoinWithoutCode;
const isAnonymous = useMessageComposerIsAnonymous();
const isSelectingMessages = useIsSelecting();
const isBlockedOrBlocker = useMessageComposerIsBlocked({ subscription: props.subscription });
const isArchived = useMessageComposerIsArchived(room, props.subscription);
const isReadOnly = useMessageComposerIsReadOnly(room);
const isOmnichannel = isOmnichannelRoom(room);
const isFederation = isRoomFederated(room);
const isFederationBlocked = !isRoomNativeFederated(room);
const [isAirGappedRestricted] = useAirGappedRestriction();
if (isAirGappedRestricted) {
return <ComposerAirGappedRestricted />;
}
if (isOmnichannel) {
return <ComposerOmnichannel {...props} />;
}
if (isFederation) {
return <ComposerFederation blocked={isFederationBlocked} {...props} />;
}
if (isAnonymous) {
return <ComposerAnonymous />;
}
if (isReadOnly) {
return <ComposerReadOnly />;
}
if (isArchived) {
return <ComposerArchived />;
}
if (mustJoinWithCode) {
return <ComposerJoinWithPassword />;
}
if (isBlockedOrBlocker) {
return <ComposerBlocked />;
}
if (isSelectingMessages) {
return <ComposerSelectMessages {...props} />;
}
return (
<>
{children}
<ComposerMessage {...props} />
</>
);
};
export default memo(ComposerContainer);

View File

@@ -0,0 +1,62 @@
import type { LicenseModule } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import ComposerFederation from './ComposerFederation';
import FakeRoomProvider from '../../../../../tests/mocks/client/FakeRoomProvider';
import { createFakeLicenseInfo } from '../../../../../tests/mocks/data';
jest.mock('../ComposerMessage', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div data-testid='composer-message'>{children}</div>,
}));
const appRoot = ({ enabled = true, activeModules = ['federation'] }: { enabled?: boolean; activeModules?: LicenseModule[] } = {}) =>
mockAppRoot()
.withJohnDoe()
.withTranslations('en', 'core', {
Federation_Matrix_Federated_Description_disabled: 'Federation is currently disabled on this workspace',
Federation_Matrix_join_public_rooms_is_premium: 'Join federated rooms is a Premium feature',
Federation_Matrix_Federated_Description_invalid_version:
'This room was created by an old Federation version and its blocked indeterminately. <1>Click here</1> for more information about Matrix Federation support',
})
.withSetting('Federation_Matrix_enabled', enabled ?? true)
.withEndpoint('GET', '/v1/licenses.info', () => ({
license: createFakeLicenseInfo({ activeModules }),
}))
.wrap((children) => <FakeRoomProvider>{children}</FakeRoomProvider>)
.build();
describe('ComposerFederation', () => {
it('should display blocked composer if federation version is invalid', () => {
render(<ComposerFederation blocked />, {
wrapper: appRoot(),
});
expect(screen.getByText(/This room was created by an old Federation version and its blocked indeterminately/)).toBeInTheDocument();
});
it('should display disabled composer if federation is disabled', () => {
render(<ComposerFederation />, {
wrapper: appRoot({ enabled: false }),
});
expect(screen.getByText('Federation is currently disabled on this workspace')).toBeInTheDocument();
});
it('should display disabled composer with premium message if federation addon is missing', () => {
render(<ComposerFederation />, {
wrapper: appRoot({ activeModules: [] }),
});
expect(screen.getByText('Join federated rooms is a Premium feature')).toBeInTheDocument();
});
it('should display normal composer if federation is enabled and valid', async () => {
render(<ComposerFederation />, { wrapper: appRoot() });
const composer = await screen.findByTestId('composer-message');
expect(composer).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
import type { ComposerMessageProps } from '../ComposerMessage';
import ComposerMessage from '../ComposerMessage';
import ComposerFederationDisabled from './ComposerFederationDisabled';
import ComposerFederationInvalidVersion from './ComposerFederationInvalidVersion';
import ComposerFederationJoinRoomDisabled from './ComposerFederationJoinRoomDisabled';
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
export type ComposerFederationProps = ComposerMessageProps & {
blocked?: boolean;
};
const ComposerFederation = ({ children, blocked, ...props }: ComposerFederationProps) => {
const federationEnabled = useIsFederationEnabled();
const { data: federationModuleEnabled = false } = useHasLicenseModule('federation');
if (blocked) {
return <ComposerFederationInvalidVersion />;
}
if (!federationEnabled) {
return <ComposerFederationDisabled />;
}
if (!federationModuleEnabled) {
return <ComposerFederationJoinRoomDisabled />;
}
return (
<>
{children}
<ComposerMessage {...props} />
</>
);
};
export default ComposerFederation;

View File

@@ -0,0 +1,10 @@
import { MessageFooterCallout } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
const ComposerFederationDisabled = () => {
const { t } = useTranslation();
return <MessageFooterCallout>{t('Federation_Matrix_Federated_Description_disabled')}</MessageFooterCallout>;
};
export default ComposerFederationDisabled;

View File

@@ -0,0 +1,22 @@
import { ExternalLink } from '@rocket.chat/ui-client';
import { MessageFooterCallout, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { Trans } from 'react-i18next';
import { links } from '../../../../lib/links';
const ComposerFederationInvalidVersion = () => {
return (
<MessageFooterCallout>
<MessageFooterCalloutContent>
<Trans
i18nKey='Federation_Matrix_Federated_Description_invalid_version'
components={{
1: <ExternalLink to={links.go.matrixFederation} />,
}}
/>
</MessageFooterCalloutContent>
</MessageFooterCallout>
);
};
export default ComposerFederationInvalidVersion;

View File

@@ -0,0 +1,10 @@
import { MessageFooterCallout } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
const ComposerFederationJoinRoomDisabled = () => {
const { t } = useTranslation();
return <MessageFooterCallout>{t('Federation_Matrix_join_public_rooms_is_premium')}</MessageFooterCallout>;
};
export default ComposerFederationJoinRoomDisabled;

View File

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

View File

@@ -0,0 +1,52 @@
import { PasswordInput } from '@rocket.chat/fuselage';
import { MessageFooterCallout, MessageFooterCalloutAction, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { Controller, useForm } from 'react-hook-form';
import { useRoom } from '../contexts/RoomContext';
const ComposerJoinWithPassword = () => {
const t = useTranslation();
const room = useRoom();
const dispatchToastMessage = useToastMessageDispatch();
const joinChannelEndpoint = useEndpoint('POST', '/v1/rooms.join');
const {
control,
handleSubmit,
setError,
formState: { errors, isDirty },
} = useForm({ defaultValues: { joinCode: '' } });
const handleJoinChannel = async ({ joinCode }: { joinCode: string }) => {
try {
await joinChannelEndpoint({
roomId: room._id,
joinCode,
});
} catch (error: any) {
setError('joinCode', { type: error.errorType, message: error.error });
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<MessageFooterCallout is='form' aria-label={t('Join_with_password')} onSubmit={handleSubmit(handleJoinChannel)}>
<MessageFooterCalloutContent>{t('you_are_in_preview')}</MessageFooterCalloutContent>
<MessageFooterCalloutContent>
<Controller
name='joinCode'
control={control}
render={({ field }) => (
<PasswordInput error={errors.joinCode?.message} {...field} placeholder={t('you_are_in_preview_please_insert_the_password')} />
)}
/>
</MessageFooterCalloutContent>
<MessageFooterCalloutAction type='submit' disabled={!isDirty}>
{t('Join_with_password')}
</MessageFooterCalloutAction>
</MessageFooterCallout>
);
};
export default ComposerJoinWithPassword;

View File

@@ -0,0 +1,94 @@
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { memo, useMemo, useSyncExternalStore } from 'react';
import ComposerSkeleton from './ComposerSkeleton';
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
import { useChat } from '../contexts/ChatContext';
import { useRoom } from '../contexts/RoomContext';
import MessageBox from './messageBox/MessageBox';
export type ComposerMessageProps = {
tmid?: IMessage['_id'];
threadExists?: boolean;
children?: ReactNode;
subscription?: ISubscription;
tshow?: boolean;
previewUrls?: string[];
onResize?: () => void;
onEscape?: () => void;
onSend?: () => void;
onNavigateToNextMessage?: () => void;
onNavigateToPreviousMessage?: () => void;
onClickSelectAll?: () => void;
};
const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps) => {
const chat = useChat();
const room = useRoom();
const dispatchToastMessage = useToastMessageDispatch();
const composerProps = useMemo(
() => ({
onJoin: async (): Promise<void> => {
try {
await chat?.data?.joinRoom();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
throw error;
}
},
onSend: async ({
value: text,
tshow,
previewUrls,
isSlashCommandAllowed,
}: {
value: string;
tshow?: boolean;
previewUrls?: string[];
isSlashCommandAllowed?: boolean;
}): Promise<void> => {
try {
await chat?.action.stop('typing');
const newMessageSent = await chat?.flows.sendMessage({
text,
tshow,
previewUrls,
isSlashCommandAllowed,
tmid,
});
if (newMessageSent) onSend?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
onTyping: async (): Promise<void> => {
if (chat?.composer?.text?.trim() === '') {
await chat?.action.stop('typing');
return;
}
await chat?.action.start('typing');
},
onNavigateToPreviousMessage: () => chat?.messageEditing.toPreviousMessage(),
onNavigateToNextMessage: () => chat?.messageEditing.toNextMessage(),
}),
[chat?.data, chat?.flows, chat?.action, chat?.composer?.text, chat?.messageEditing, dispatchToastMessage, tmid, onSend],
);
const { subscribe, getSnapshotValue } = useMemo(() => {
return LegacyRoomManager.listenRoomPropsByRid(room._id, 'streamActive');
}, [room._id]);
const publicationReady = useSyncExternalStore(subscribe, getSnapshotValue);
if (!publicationReady) {
return <ComposerSkeleton />;
}
return <MessageBox key={tmid ? `${room._id}-${tmid}` : room._id} tmid={tmid} {...composerProps} showFormattingTips={true} {...props} />;
};
export default memo(ComposerMessage);

View File

@@ -0,0 +1,79 @@
import { MessageFooterCallout } from '@rocket.chat/ui-composer';
import { useUserId } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useIsRoomOverMacLimit } from '../../../omnichannel/hooks/useIsRoomOverMacLimit';
import { useOmnichannelRoom, useUserIsSubscribed } from '../../contexts/RoomContext';
import type { ComposerMessageProps } from '../ComposerMessage';
import ComposerMessage from '../ComposerMessage';
import ComposerOmnichannelCallout from './ComposerOmnichannelCallout';
import { ComposerOmnichannelInquiry } from './ComposerOmnichannelInquiry';
import { ComposerOmnichannelJoin } from './ComposerOmnichannelJoin';
import { ComposerOmnichannelOnHold } from './ComposerOmnichannelOnHold';
const ComposerOmnichannel = (props: ComposerMessageProps) => {
const { t } = useTranslation();
const userId = useUserId();
const room = useOmnichannelRoom();
const { servedBy, queuedAt, open, onHold } = room;
const isSubscribed = useUserIsSubscribed();
const isInquired = !servedBy && queuedAt;
const isSameAgent = servedBy?._id === userId;
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
if (!open) {
return (
<>
<ComposerOmnichannelCallout />
<MessageFooterCallout color='default'>{t('This_conversation_is_already_closed')}</MessageFooterCallout>
</>
);
}
if (isRoomOverMacLimit) {
return (
<>
<ComposerOmnichannelCallout />
<MessageFooterCallout color='default'>{t('Workspace_exceeded_MAC_limit_disclaimer')}</MessageFooterCallout>
</>
);
}
if (onHold) {
return (
<>
<ComposerOmnichannelCallout />
<ComposerOmnichannelOnHold />
</>
);
}
if (isInquired) {
return (
<>
<ComposerOmnichannelCallout />
<ComposerOmnichannelInquiry />
</>
);
}
if (!isSubscribed && !isSameAgent) {
return (
<>
<ComposerOmnichannelCallout />
<ComposerOmnichannelJoin />
</>
);
}
return (
<>
<ComposerOmnichannelCallout />
<ComposerMessage {...props} />
</>
);
};
export default ComposerOmnichannel;

View File

@@ -0,0 +1,75 @@
import { faker } from '@faker-js/faker/locale/af_ZA';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ComposerOmnichannelCallout from './ComposerOmnichannelCallout';
import FakeRoomProvider from '../../../../../tests/mocks/client/FakeRoomProvider';
import { createFakeContact, createFakeRoom } from '../../../../../tests/mocks/data';
jest.mock('../../../omnichannel/contactInfo/tabs/ContactInfoChannels/useBlockChannel', () => ({
useBlockChannel: () => jest.fn(),
}));
const fakeVisitor = {
_id: faker.string.uuid(),
token: faker.string.uuid(),
username: faker.internet.userName(),
};
const fakeRoom = createFakeRoom<IOmnichannelRoom>({ t: 'l', v: fakeVisitor });
const fakeContact = createFakeContact();
it('should be displayed if contact is unknown', async () => {
const getContactMockFn = jest.fn().mockResolvedValue({ contact: fakeContact });
const wrapper = mockAppRoot().withEndpoint('GET', '/v1/omnichannel/contacts.get', getContactMockFn).withRoom(fakeRoom);
render(
<FakeRoomProvider roomOverrides={fakeRoom}>
<ComposerOmnichannelCallout />
</FakeRoomProvider>,
{ wrapper: wrapper.build() },
);
await waitFor(() => expect(getContactMockFn).toHaveBeenCalled());
expect(screen.getByText('Unknown_contact_callout_description')).toBeVisible();
expect(screen.getByRole('button', { name: 'Add_contact' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Block' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible();
});
it('should not be displayed if contact is known', async () => {
const getContactMockFn = jest.fn().mockResolvedValue({ contact: createFakeContact({ unknown: false }) });
const wrapper = mockAppRoot().withEndpoint('GET', '/v1/omnichannel/contacts.get', getContactMockFn).withRoom(fakeRoom);
render(
<FakeRoomProvider roomOverrides={fakeRoom}>
<ComposerOmnichannelCallout />
</FakeRoomProvider>,
{ wrapper: wrapper.build() },
);
await waitFor(() => expect(getContactMockFn).toHaveBeenCalled());
expect(screen.queryByText('Unknown_contact_callout_description')).not.toBeInTheDocument();
});
it('should hide callout on dismiss', async () => {
const getContactMockFn = jest.fn().mockResolvedValue({ contact: fakeContact });
const wrapper = mockAppRoot().withEndpoint('GET', '/v1/omnichannel/contacts.get', getContactMockFn).withRoom(fakeRoom);
render(
<FakeRoomProvider roomOverrides={fakeRoom}>
<ComposerOmnichannelCallout />
</FakeRoomProvider>,
{ wrapper: wrapper.build() },
);
await waitFor(() => expect(getContactMockFn).toHaveBeenCalled());
expect(screen.getByText('Unknown_contact_callout_description')).toBeVisible();
const btnDismiss = screen.getByRole('button', { name: 'Dismiss' });
await userEvent.click(btnDismiss);
expect(screen.queryByText('Unknown_contact_callout_description')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,61 @@
import { Button, ButtonGroup, Callout, IconButton } from '@rocket.chat/fuselage';
import { useSessionStorage } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { isSameChannel } from '../../../../../app/livechat/lib/isSameChannel';
import { useBlockChannel } from '../../../omnichannel/contactInfo/tabs/ContactInfoChannels/useBlockChannel';
import { useOmnichannelRoom } from '../../contexts/RoomContext';
const ComposerOmnichannelCallout = () => {
const { t } = useTranslation();
const room = useOmnichannelRoom();
const { navigate } = useRouter();
const {
_id,
v: { _id: visitorId },
source,
contactId,
} = room;
const calloutDescriptionId = useId();
const [dismissed, setDismissed] = useSessionStorage(`contact-unknown-callout-${contactId}`, false);
const getContactById = useEndpoint('GET', '/v1/omnichannel/contacts.get');
const { data } = useQuery({ queryKey: ['getContactById', contactId], queryFn: () => getContactById({ contactId }) });
const association = { visitorId, source };
const currentChannel = data?.contact?.channels?.find((channel) => isSameChannel(channel.visitor, association));
const handleBlock = useBlockChannel({ blocked: currentChannel?.blocked || false, association });
if (dismissed || !data?.contact?.unknown) {
return null;
}
return (
<Callout
role='status'
aria-labelledby={calloutDescriptionId}
marginBlockEnd={16}
actions={
<ButtonGroup>
<Button onClick={() => navigate(`/live/${_id}/contact-profile/edit`)} small>
{t('Add_contact')}
</Button>
<Button danger secondary small onClick={handleBlock}>
{currentChannel?.blocked ? t('Unblock') : t('Block')}
</Button>
<IconButton icon='cross' secondary small title={t('Dismiss')} onClick={() => setDismissed(true)} />
</ButtonGroup>
}
>
<p id={calloutDescriptionId}>{t('Unknown_contact_callout_description')}</p>
</Callout>
);
};
export default ComposerOmnichannelCallout;

View File

@@ -0,0 +1,63 @@
import { MessageFooterCallout, MessageFooterCalloutAction, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useEndpoint, useToastMessageDispatch, useTranslation, useUser } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useOmnichannelAgentAvailable } from '../../../omnichannel/hooks/useOmnichannelAgentAvailable';
import { useOmnichannelRoom } from '../../contexts/RoomContext';
export const ComposerOmnichannelInquiry = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const user = useUser();
const agentAvailable = useOmnichannelAgentAvailable();
const room = useOmnichannelRoom();
const getInquire = useEndpoint('GET', `/v1/livechat/inquiries.getOne`);
const result = useQuery({
queryKey: ['inquire', room._id],
queryFn: () =>
getInquire({
roomId: room._id,
}),
});
const takeInquiry = useEndpoint('POST', '/v1/livechat/inquiries.take');
const handleTakeInquiry = async (): Promise<void> => {
if (!result.isSuccess) {
return;
}
if (!result.data.inquiry) {
return;
}
try {
await takeInquiry({ inquiryId: result.data.inquiry._id, options: { clientAction: true } });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const title = useMemo(() => {
if (user?.status === 'offline') {
return t('You_cant_take_chats_offline');
}
if (!agentAvailable) {
return t('You_cant_take_chats_unavailable');
}
}, [agentAvailable, t, user?.status]);
return (
<MessageFooterCallout aria-busy={result.isPending}>
<MessageFooterCalloutContent>{t('you_are_in_preview_mode_of_incoming_livechat')}</MessageFooterCalloutContent>
<MessageFooterCalloutAction
{...(title && { title })}
disabled={result.isPending || user?.status === 'offline' || !agentAvailable}
onClick={handleTakeInquiry}
>
{t('Take_it')}
</MessageFooterCalloutAction>
</MessageFooterCallout>
);
};

View File

@@ -0,0 +1,32 @@
import { MessageFooterCallout, MessageFooterCalloutAction, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useOmnichannelRoom } from '../../contexts/RoomContext';
export const ComposerOmnichannelJoin = () => {
const room = useOmnichannelRoom();
const join = useEndpoint('GET', `/v1/livechat/room.join`);
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
return (
<MessageFooterCallout>
<MessageFooterCalloutContent>{t('room_is_read_only')}</MessageFooterCalloutContent>
<MessageFooterCalloutAction
onClick={async (): Promise<void> => {
try {
await join({
roomId: room._id,
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}}
>
{t('Join')}
</MessageFooterCalloutAction>
</MessageFooterCallout>
);
};

View File

@@ -0,0 +1,25 @@
import { MessageFooterCallout, MessageFooterCalloutAction, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
import { useResumeChatOnHoldMutation } from './hooks/useResumeChatOnHoldMutation';
import { useOmnichannelRoom } from '../../contexts/RoomContext';
export const ComposerOmnichannelOnHold = () => {
const resumeChatOnHoldMutation = useResumeChatOnHoldMutation();
const room = useOmnichannelRoom();
const { t } = useTranslation();
return (
<MessageFooterCallout>
<MessageFooterCalloutContent>{t('chat_on_hold_due_to_inactivity')}</MessageFooterCalloutContent>
<MessageFooterCalloutAction
disabled={resumeChatOnHoldMutation.isPending}
onClick={(): void => resumeChatOnHoldMutation.mutate(room._id)}
>
{t('Resume')}
</MessageFooterCalloutAction>
</MessageFooterCallout>
);
};

View File

@@ -0,0 +1,32 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { roomsQueryKeys, subscriptionsQueryKeys } from '../../../../../lib/queryKeys';
export const useResumeChatOnHoldMutation = (
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
): UseMutationResult<void, Error, IRoom['_id']> => {
const resumeChatOnHold = useEndpoint('POST', '/v1/livechat/room.resumeOnHold');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (roomId) => {
await resumeChatOnHold({ roomId });
},
...options,
onSuccess: async (data, rid, context) => {
await queryClient.invalidateQueries({ queryKey: ['current-chats'] });
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.room(rid) });
await queryClient.invalidateQueries({ queryKey: subscriptionsQueryKeys.subscription(rid) });
return options?.onSuccess?.(data, rid, context);
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
};

View File

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

View File

@@ -0,0 +1,37 @@
import { Button } from '@rocket.chat/fuselage';
import { MessageFooterCallout, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useRoom, useUserIsSubscribed } from '../contexts/RoomContext';
const ComposerReadOnly = () => {
const { t } = useTranslation();
const room = useRoom();
const isSubscribed = useUserIsSubscribed();
const joinChannel = useEndpoint('POST', '/v1/rooms.join');
const dispatchToastMessage = useToastMessageDispatch();
const join = useMutation({
mutationFn: () => joinChannel({ roomId: room._id }),
onError: (error: unknown) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
return (
<MessageFooterCallout>
<MessageFooterCalloutContent>{t('room_is_read_only')}</MessageFooterCalloutContent>
{!isSubscribed && (
<Button primary onClick={() => join.mutate()} loading={join.isPending}>
{t('Join')}
</Button>
)}
</MessageFooterCallout>
);
};
export default ComposerReadOnly;

View File

@@ -0,0 +1,32 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { MessageFooterCallout, MessageFooterCalloutContent } from '@rocket.chat/ui-composer';
import { useTranslation } from 'react-i18next';
import type { ComposerMessageProps } from './ComposerMessage';
import { useCountSelected, useClearSelection, useAvailableMessagesCount } from '../MessageList/contexts/SelectedMessagesContext';
const ComposerSelectMessages = ({ onClickSelectAll }: ComposerMessageProps) => {
const { t } = useTranslation();
const clearSelection = useClearSelection();
const countSelected = useCountSelected();
const countAvailable = useAvailableMessagesCount();
return (
<MessageFooterCallout>
<MessageFooterCalloutContent textAlign='left'>
{t('__count__messages_selected', { count: countSelected })}
</MessageFooterCalloutContent>
<ButtonGroup>
<Button small disabled={countSelected === 0} onClick={clearSelection}>
{t('Clear_selection')}
</Button>
<Button icon='arrow-up' small primary disabled={countAvailable === 0} onClick={onClickSelectAll}>
{t('Select__count__messages', { count: countAvailable })}
</Button>
</ButtonGroup>
</MessageFooterCallout>
);
};
export default ComposerSelectMessages;

View File

@@ -0,0 +1,13 @@
import { Box } from '@rocket.chat/fuselage';
import { MessageComposerSkeleton } from '@rocket.chat/ui-composer';
const ComposerSkeleton = () => {
return (
<>
<MessageComposerSkeleton />
<Box height='x24' />
</>
);
};
export default ComposerSkeleton;

View File

@@ -0,0 +1,80 @@
import { Box } from '@rocket.chat/fuselage';
import { useCallback, Fragment, useSyncExternalStore, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { UserAction, USER_ACTIVITIES } from '../../../../lib/UserAction';
const maxUsernames = 5;
const ACTION_PRIORITY: Record<string, number> = {
[USER_ACTIVITIES.USER_RECORDING]: 0,
[USER_ACTIVITIES.USER_UPLOADING]: 1,
[USER_ACTIVITIES.USER_TYPING]: 2,
[USER_ACTIVITIES.USER_PLAYING]: 3,
};
export type ComposerUserActionIndicatorProps = { rid: string; tmid?: string };
const ComposerUserActionIndicator = ({ rid, tmid }: ComposerUserActionIndicatorProps) => {
const { t } = useTranslation();
const roomAction = useSyncExternalStore(
UserAction.subscribe,
useCallback(() => UserAction.get(tmid || rid), [rid, tmid]),
);
const actions = useMemo(() => {
const usersRendered = new Set<string>();
return Object.entries(roomAction ?? {})
.sort(([a], [b]) => ACTION_PRIORITY[a] - ACTION_PRIORITY[b])
.map(([key, _users]) => {
const action = key.split('-')[1];
const users = Object.keys(_users);
if (users.length === 0) {
return;
}
const filteredUsers = users.filter((user) => !usersRendered.has(user));
if (filteredUsers.length === 0) {
return;
}
for (const user of filteredUsers) {
usersRendered.add(user);
}
return {
action,
users: filteredUsers,
};
})
.filter(Boolean) as {
action: 'typing' | 'recording' | 'uploading' | 'playing';
users: string[];
}[];
}, [roomAction]);
return (
<Box
role='status'
height='x20'
className='rc-message-box__activity-wrapper'
fontScale='c1'
color='annotation'
display='flex'
alignItems='center'
>
{actions.map(({ action, users }, index) => (
<Fragment key={action}>
{index > 0 && ', '}
{users.length < maxUsernames
? users.join(', ')
: `${users.slice(0, maxUsernames - 1).join(', ')} ${t('and')} ${t('others')}`}{' '}
{users.length > 1 ? t(`are_${action}`) : t(`is_${action}`)}
</Fragment>
))}
</Box>
);
};
export default ComposerUserActionIndicator;

View File

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

View File

@@ -0,0 +1,37 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useLayout } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes } from 'react';
const customStyle = css`
&.rc-message-box {
position: relative;
width: 100%;
padding: 0 24px;
&.embedded {
min-height: 36px;
padding: 0;
& .users-typing {
display: none;
}
& .formatting-tips {
display: none;
}
}
}
`;
const RoomComposer = ({ children, ...props }: Omit<AllHTMLAttributes<HTMLElement>, 'is'>) => {
const { isEmbedded } = useLayout();
return (
<Box is='footer' className={[customStyle, 'rc-message-box', isEmbedded && 'embedded'].filter(Boolean)} {...props}>
{children}
</Box>
);
};
export default RoomComposer;

View File

@@ -0,0 +1,11 @@
import { memo } from 'react';
import ComposerSkeleton from '../ComposerSkeleton';
import RoomComposer from './RoomComposer';
const RoomComposerSkeleton = () => (
<RoomComposer>
<ComposerSkeleton />
</RoomComposer>
);
export default memo(RoomComposerSkeleton);

View File

@@ -0,0 +1,63 @@
import { useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import type { CSSProperties, MutableRefObject, RefCallback } from 'react';
import { useCallback } from 'react';
function shouldScrollToBottom(textarea: HTMLTextAreaElement) {
const isCursorAtBottom = textarea.selectionEnd === textarea.value.length;
const isScrolledToBottom = textarea.scrollTop + textarea.clientHeight === textarea.scrollHeight;
return isCursorAtBottom || isScrolledToBottom;
}
export const useAutoGrow = (
ref: MutableRefObject<HTMLTextAreaElement | null>,
hideTextArea?: boolean,
): {
textAreaStyle: CSSProperties;
autoGrowRef: RefCallback<HTMLTextAreaElement>;
} => {
const autoGrowRef = useSafeRefCallback(
useCallback(
(node: HTMLTextAreaElement) => {
ref.current = node;
const resize = () => {
const shouldScroll = shouldScrollToBottom(node);
node.style.height = '0';
node.style.height = `${node.scrollHeight}px`;
if (shouldScroll) {
node.scrollTop = node.scrollHeight;
}
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(node);
node.addEventListener('input', resize);
return () => {
resizeObserver.disconnect();
node.removeEventListener('input', resize);
};
},
[ref],
),
);
return {
autoGrowRef,
textAreaStyle: {
...(hideTextArea && {
visibility: 'hidden',
}),
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
overflowWrap: 'break-word',
willChange: 'contents',
wordBreak: 'normal',
},
};
};

View File

@@ -0,0 +1,285 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { UseQueryResult } from '@tanstack/react-query';
import type { MutableRefObject } from 'react';
import { useEffect, useCallback, useState, useRef } from 'react';
import { useComposerBoxPopupQueries } from './useComposerBoxPopupQueries';
import { useChat } from '../../contexts/ChatContext';
import type { ComposerPopupOption } from '../../contexts/ComposerPopupContext';
type ComposerBoxPopupImperativeCommands<T> = MutableRefObject<
| {
getFilter?: () => string;
select?: (s: T) => void;
}
| undefined
>;
type ComposerBoxPopupOptions<T extends { _id: string; sort?: number | undefined }> = ComposerPopupOption<T>;
type ComposerBoxPopupResult<T extends { _id: string; sort?: number }> =
| {
option: ComposerPopupOption<T>;
items: UseQueryResult<T[]>[];
focused: T | undefined;
select: (item: T) => void;
callbackRef: (node: HTMLElement) => void;
commandsRef: ComposerBoxPopupImperativeCommands<T>;
suspended: boolean;
filter: unknown;
clear: () => void;
}
| {
option: undefined;
items: undefined;
focused: undefined;
callbackRef: (node: HTMLElement) => void;
select: undefined;
commandsRef: ComposerBoxPopupImperativeCommands<T>;
suspended: undefined;
filter: unknown;
clear: () => void;
};
const keys = {
TAB: 9,
ENTER: 13,
ESC: 27,
ARROW_UP: 38,
ARROW_DOWN: 40,
} as const;
export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
options: ComposerBoxPopupOptions<T>[],
): ComposerBoxPopupResult<T> => {
const [optionIndex, setOptionIndex] = useState<number>(-1);
const [focused, setFocused] = useState<T | undefined>(undefined);
const [filter, setFilter] = useState('');
const option = options[optionIndex];
const commandsRef: ComposerBoxPopupImperativeCommands<T> = useRef(undefined);
const { queries: items, suspended } = useComposerBoxPopupQueries(filter, option) as {
queries: UseQueryResult<T[]>[];
suspended: boolean;
};
const chat = useChat();
useEffect(() => {
if (!option) {
return;
}
if (option?.preview && suspended) {
setFocused(undefined);
return;
}
setFocused((focused) => {
const sortedItems = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
return sortedItems.find((item) => item._id === focused?._id) ?? sortedItems[0];
});
}, [items, option, suspended]);
const select = useStableCallback((item: T) => {
if (!option) {
throw new Error('No popup is open');
}
if (commandsRef.current?.select) {
commandsRef.current.select(item);
} else {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
const selector =
option.matchSelectorRegex ??
(option.triggerAnywhere ? new RegExp(`(?:^| |\n)(${option.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${option.trigger})([^\\s]*$)`));
const result = value?.match(selector);
if (!result || !value) {
return;
}
chat?.composer?.replaceText((option.prefix ?? option.trigger ?? '') + option.getValue(item) + (option.suffix ?? ''), {
start: value.lastIndexOf(result[1] + result[2]),
end: chat?.composer?.selection.start,
});
}
setOptionIndex(-1);
setFocused(undefined);
});
const setOptionByInput = useStableCallback((): ComposerBoxPopupOptions<T> | undefined => {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
if (!value) {
setOptionIndex(-1);
setFocused(undefined);
return;
}
const optionIndex = options.findIndex(({ trigger, matchSelectorRegex, triggerAnywhere, triggerLength }) => {
const selector =
matchSelectorRegex ?? (triggerAnywhere ? new RegExp(`(?:^| |\n)(${trigger})[^\\s]*$`) : new RegExp(`(?:^)(${trigger})[^\\s]*$`));
const result = selector.test(value);
if (!triggerLength || !result) {
return result;
}
const filter = value.match(selector);
return filter && triggerLength < filter[0].length;
});
setOptionIndex(optionIndex);
const option = options[optionIndex];
if (!option) {
setFocused(undefined);
setFilter('');
}
if (option) {
const selector =
option.matchSelectorRegex ??
(option.triggerAnywhere ? new RegExp(`(?:^| |\n)(${option.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${option.trigger})([^\\s]*$)`));
const result = value.match(selector);
setFilter(commandsRef.current?.getFilter?.() ?? (result ? result[2] : ''));
}
return option;
});
const handleFocus = useStableCallback(() => {
if (option) {
return;
}
setOptionByInput();
});
const handleInput = useStableCallback(() => {
setOptionByInput();
});
const handleKeyUp = useStableCallback((event: KeyboardEvent) => {
if (event.which === keys.ESC) {
if (option?.closeOnEsc === true) {
setOptionIndex(-1);
setFocused(undefined);
event.preventDefault();
event.stopImmediatePropagation();
}
return;
}
setOptionByInput();
});
const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
if (!option) {
return;
}
if (event.which === keys.ENTER || event.which === keys.TAB) {
if (!focused) {
return;
}
select(focused);
event.preventDefault();
event.stopImmediatePropagation();
return true;
}
if (event.which === keys.ARROW_UP && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) {
setFocused((focused) => {
const list = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
if (!list) {
return;
}
const focusedIndex = list.findIndex((item) => item === focused);
return focusedIndex > 0 ? list[focusedIndex - 1] : list[list.length - 1];
});
event.preventDefault();
event.stopImmediatePropagation();
return true;
}
if (event.which === keys.ARROW_DOWN && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)) {
setFocused((focused) => {
const list = items
.filter((item) => item.isSuccess)
.flatMap((item) => item.data)
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
if (!list) {
return undefined;
}
const focusedIndex = list.findIndex((item) => item === focused);
return focusedIndex < list.length - 1 ? list[focusedIndex + 1] : list[0];
});
event.preventDefault();
event.stopImmediatePropagation();
return true;
}
});
const clear = useStableCallback(() => {
setOptionIndex(-1);
setFocused(undefined);
setFilter('');
});
const ref = useRef<HTMLElement | null>(null);
const callbackRef = useCallback(
(node: HTMLElement | null) => {
if (ref.current) {
ref.current.removeEventListener('input', handleInput);
ref.current.removeEventListener('keyup', handleKeyUp);
ref.current.removeEventListener('keydown', handleKeyDown);
ref.current.removeEventListener('focus', handleFocus);
ref.current = null;
}
if (node) {
ref.current = node;
node.addEventListener('input', handleInput);
node.addEventListener('keyup', handleKeyUp);
node.addEventListener('keydown', handleKeyDown);
node.addEventListener('focus', handleFocus);
}
},
[handleInput, handleKeyUp, handleKeyDown, handleFocus],
);
if (!option) {
return {
option: undefined,
items: undefined,
focused: undefined,
select: undefined,
callbackRef,
commandsRef,
suspended: undefined,
filter: undefined,
clear,
};
}
return {
option,
items,
focused,
select,
callbackRef,
commandsRef,
suspended,
filter,
clear,
};
};

View File

@@ -0,0 +1,51 @@
import { keepPreviousData, useQueries } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useEnablePopupPreview } from './useEnablePopupPreview';
import { slashCommands } from '../../../../lib/slashCommand';
import type { ComposerPopupOption } from '../../contexts/ComposerPopupContext';
export const useComposerBoxPopupQueries = <T extends { _id: string; sort?: number }>(filter: unknown, popup?: ComposerPopupOption<T>) => {
const [counter, setCounter] = useState(0);
useEffect(() => {
setCounter(0);
}, [popup, filter]);
const shouldPopupPreview = useEnablePopupPreview(filter, popup);
const enableQuery =
!popup ||
(popup.preview &&
Boolean(slashCommands.commands[(filter as any)?.cmd]) &&
slashCommands.commands[(filter as any)?.cmd].providesPreview) ||
shouldPopupPreview;
const queries = useQueries({
queries: [
{
placeholderData: keepPreviousData,
queryKey: ['message-popup', 'local', filter, popup],
queryFn: () => popup?.getItemsFromLocal?.(filter) || [],
enabled: enableQuery,
},
{
placeholderData: keepPreviousData,
queryKey: ['message-popup', 'server', filter, popup],
queryFn: () => popup?.getItemsFromServer?.(filter) || [],
enabled: counter > 0,
},
],
});
useEffect(() => {
if (Array.isArray(queries[0].data) && queries[0].data.length < 5) {
setCounter(1);
}
}, [queries]);
return {
queries,
suspended: !enableQuery,
};
};

View File

@@ -0,0 +1,4 @@
import type { ComposerPopupOption } from '../../contexts/ComposerPopupContext';
export const useEnablePopupPreview = <T extends { _id: string; sort?: number }>(filter: unknown, popup?: ComposerPopupOption<T>) =>
popup && !popup.preview && (popup?.triggerLength ? typeof filter === 'string' && popup.triggerLength - 1 < filter.length : true);

View File

@@ -0,0 +1,13 @@
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
export const useMessageComposerIsAnonymous = (): boolean => {
const isAnonymousReadEnabled = useSetting('Accounts_AllowAnonymousRead');
const isAnonymousWriteEnabled = useSetting('Accounts_AllowAnonymousWrite');
const uid = useUserId();
if (!uid && !isAnonymousReadEnabled && !isAnonymousWriteEnabled) {
throw new Error('Anonymous access is disabled');
}
return Boolean(!uid);
};

View File

@@ -0,0 +1,4 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
export const useMessageComposerIsArchived = (room: IRoom, subscription?: ISubscription): boolean =>
!!room?.archived || Boolean(subscription && subscription.t === 'd' && subscription.archived);

View File

@@ -0,0 +1,19 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useRoom } from '../../contexts/RoomContext';
export const useMessageComposerIsBlocked = ({ subscription }: { subscription?: ISubscription }): boolean => {
const room = useRoom();
if (!isDirectMessageRoom(room)) {
return false;
}
if (!subscription) {
return false;
}
// over DM's is possible to block or being blocked by the other user
const isBlocked = Boolean(subscription.blocked);
const isBlocker = Boolean(subscription.blocker);
return isBlocked || isBlocker;
};

View File

@@ -0,0 +1,14 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { usePermission, useUser } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
export const useMessageComposerIsReadOnly = (room: IRoom): boolean => {
const user = useUser();
// depend on post-readonly so this re-runs when the permission is granted/revoked at runtime;
// roomCoordinator.readOnly calls hasPermission internally and returns the up-to-date value.
const postReadOnly = usePermission('post-readonly', room._id);
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => roomCoordinator.readOnly(room, user), [room, user, postReadOnly]);
};

View File

@@ -0,0 +1,30 @@
import type { MutableRefObject, Ref, RefCallback } from 'react';
import { useCallback } from 'react';
const isRefCallback = <T>(x: unknown): x is RefCallback<T> => typeof x === 'function';
const isMutableRefObject = <T>(x: unknown): x is MutableRefObject<T> => typeof x === 'object';
export const setRef = <T>(ref: Ref<T> | undefined, refValue: T) => {
if (isRefCallback<T>(ref)) {
ref(refValue);
return;
}
if (isMutableRefObject<T>(ref)) {
ref.current = refValue;
}
};
/**
* Merges multiple refs into a single ref callback.
* it was not meant to be used with in any different place than MessageBox
*
* @param refs The refs to merge.
* @returns The merged ref callback.
*/
export const useMessageComposerMergedRefs = <T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> => {
return useCallback((refValue: T) => {
refs.forEach((ref) => setRef(ref, refValue));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs);
};

View File

@@ -0,0 +1,110 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MessageBox from './MessageBox';
import type { createComposerAPI } from './createComposerAPI';
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
import { useFileUpload } from '../../body/hooks/useFileUpload';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: { getRoomDirectives: () => ({ canSendMessage: () => true }) },
}));
jest.mock('../../../../lib/getURL', () => ({ getURL: (url: string) => url }));
jest.mock('../../hooks/useE2EERoomState', () => ({ useE2EERoomState: jest.fn() }));
jest.mock('../../contexts/ChatContext', () => ({ useChat: jest.fn() }));
jest.mock('../../contexts/RoomContext', () => ({ useRoom: jest.fn(), useRoomSubscription: jest.fn() }));
jest.mock('../../contexts/ComposerPopupContext', () => ({ useComposerPopupOptions: jest.fn() }));
jest.mock('../../body/hooks/useFileUpload', () => ({ useFileUpload: jest.fn() }));
const createChatStub = () => {
const chat = {
composer: undefined as ReturnType<typeof createComposerAPI> | undefined,
setComposerAPI: (composer?: ReturnType<typeof createComposerAPI>) => {
chat.composer?.release();
chat.composer = composer;
},
currentEditingMessage: { getMID: () => undefined },
action: { start: jest.fn(), stop: jest.fn(), performContinuously: jest.fn() },
emojiPicker: { open: jest.fn(), close: jest.fn() },
flows: {},
};
return chat as unknown as ChatAPI & { composer?: ReturnType<typeof createComposerAPI> };
};
const renderMessageBox = (onSend: jest.Mock, endpointHandler: jest.Mock) => {
const chat = createChatStub();
(useChat as jest.Mock).mockReturnValue(chat);
const view = render(<MessageBox showFormattingTips={false} onSend={onSend} />, {
wrapper: mockAppRoot().withUserPreference('sendOnEnter', 'normal').withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(),
});
return { chat, ...view };
};
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'rid', t: 'c', name: 'general' });
(useRoomSubscription as jest.Mock).mockReturnValue({ rid: 'rid' });
(useComposerPopupOptions as jest.Mock).mockReturnValue([]);
(useFileUpload as jest.Mock).mockReturnValue({
hasUploads: false,
handleUploadFiles: jest.fn(),
isUploading: false,
isProcessingUploads: false,
});
});
afterEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
describe('MessageBox drafts', () => {
it('should not discard a newer draft when a pending send resolves after the room changed', async () => {
const user = userEvent.setup();
const endpointHandler = jest.fn(() => null);
let resolveSend: (() => void) | undefined;
// Mirrors the real send flow: it clears the composer, then awaits the server response.
const onSend = jest.fn(() => {
chat.composer?.clear();
return new Promise<void>((resolve) => {
resolveSend = () => resolve();
});
});
const { chat, unmount } = renderMessageBox(onSend, endpointHandler);
const composer = screen.getByRole('textbox', { name: 'Message #general' });
// Send a message and keep its response pending.
await user.type(composer, 'message A');
// The composer listens for `which`, which userEvent does not set.
// eslint-disable-next-line testing-library/prefer-user-event
fireEvent.keyDown(composer, { key: 'Enter', which: 13, keyCode: 13 });
await waitFor(() => expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ value: 'message A' })));
// Type a new draft while the send is still in flight.
await user.type(composer, 'draft B');
await waitFor(() => expect(localStorage.getItem('messagebox_rid')).toBe('draft B'));
// Leave the room: the draft is flushed to the server on unmount.
unmount();
await waitFor(() => expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'draft B' }));
// The delayed send finally resolves.
await act(async () => {
resolveSend?.();
});
expect(endpointHandler).toHaveBeenCalledTimes(1);
expect(endpointHandler).not.toHaveBeenCalledWith(expect.objectContaining({ draft: '' }));
});
});

View File

@@ -0,0 +1,435 @@
/* eslint-disable complexity */
import { isRoomFederated, isRoomNativeFederated, type IMessage, type ISubscription } from '@rocket.chat/core-typings';
import { useContentBoxSize, useStableCallback, useMediaQuery, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { MessageComposerInputExpandable } from '@rocket.chat/ui-composer';
import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import type { MouseEvent, ClipboardEvent, ChangeEvent } from 'react';
import { memo, useRef, useReducer, useCallback, useSyncExternalStore } from 'react';
import MessageBoxBase from './MessageBoxBase';
import MessageComposerFiles from './MessageComposerFiles';
import { createComposerAPI } from './createComposerAPI';
import { useDraft } from './hooks/useDraft';
import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus';
import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder';
import { emptySubscribe, getEmptyFalse, getEmptyArray, handleFormattingShortcut } from './messageBoxHelpers';
import { handleSelectionWrapping } from './wrapSelection';
import { getImageExtensionFromMime } from '../../../../../lib/getImageExtensionFromMime';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
import { emoji } from '../../../../lib/emoji';
import { formattingButtons } from '../../../../lib/messageBoxFormatting';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { keyCodes } from '../../../../lib/utils/keyCodes';
import { Subscriptions } from '../../../../stores';
import { useFileUpload } from '../../body/hooks/useFileUpload';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
import { useAutoGrow } from '../RoomComposer/hooks/useAutoGrow';
import { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview';
import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs';
const reducer = (_: unknown, event: ChangeEvent<HTMLInputElement>): boolean => {
const { target } = event;
return Boolean(target.value.trim());
};
export type MessageBoxProps = {
tmid?: IMessage['_id'];
onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise<void>;
onJoin?: () => Promise<void>;
onResize?: () => void;
onTyping?: () => void;
onEscape?: () => void;
onNavigateToPreviousMessage?: () => void;
onNavigateToNextMessage?: () => void;
tshow?: IMessage['tshow'];
previewUrls?: string[];
subscription?: ISubscription;
showFormattingTips: boolean;
isEmbedded?: boolean;
threadExists?: boolean;
};
const MessageBox = ({
tmid,
onSend,
onJoin,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onEscape,
onTyping,
tshow,
previewUrls,
threadExists,
}: MessageBoxProps) => {
const chat = useChat();
const room = useRoom();
const t = useTranslation();
const e2eEnabled = useSetting('E2E_Enable', false);
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed;
const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room);
const quoteChainLimit = useSetting('Message_QuoteChainLimit', 2);
const [typing, setTyping] = useReducer(reducer, false);
const { isMobile } = useLayout();
const sendOnEnterBehavior = useUserPreference<'normal' | 'alternative' | 'desktop'>('sendOnEnter') || isMobile;
const sendOnEnter = sendOnEnterBehavior == null || sendOnEnterBehavior === 'normal' || (sendOnEnterBehavior === 'desktop' && !isMobile);
if (!chat) {
throw new Error('Chat context not found');
}
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageComposerRef = useRef<HTMLElement>(null);
const subscription = useRoomSubscription();
const { initialValue, persistLocal, flushDraft } = useDraft(
room._id,
tmid ? subscription?.threadDrafts?.[tmid] : subscription?.draft,
tmid,
threadExists,
);
const callbackRef = useCallback(
(node: HTMLTextAreaElement) => {
if (node === null && chat.composer) {
flushDraft();
return chat.setComposerAPI();
}
if (chat.composer) {
return;
}
chat.setComposerAPI(
createComposerAPI(node, persistLocal, initialValue, quoteChainLimit, messageComposerRef, { rid: room._id, tmid }),
);
},
[chat, flushDraft, initialValue, persistLocal, quoteChainLimit, room._id, tmid],
);
const isTouchDevice = useMediaQuery('(pointer: coarse)');
const autofocusRef = useMessageBoxAutoFocus(!isTouchDevice);
const useEmojis = useUserPreference<boolean>('useEmojis');
const handleOpenEmojiPicker = useStableCallback((e: MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
if (!useEmojis) {
return;
}
const ref = messageComposerRef.current as HTMLElement;
chat.emojiPicker.open(ref, (emojiName: string) => {
const emojiEntry = emoji.list[`:${emojiName}:`];
const text = emojiEntry && 'unicode' in emojiEntry && emojiEntry.unicode ? ` ${emojiEntry.unicode} ` : ` :${emojiName}: `;
chat.composer?.insertText(text);
});
});
const { hasUploads, handleUploadFiles, isUploading, isProcessingUploads } = useFileUpload();
const handleSendMessage = useStableCallback(async () => {
if (isUploading || isProcessingUploads) {
return;
}
const { composer } = chat;
const text = composer?.text ?? '';
popup.clear();
void onSend?.({
value: text,
tshow,
previewUrls,
isSlashCommandAllowed,
}).then(() => {
if (!composer) {
return;
}
flushDraft(composer.text);
});
});
const closeEditing = async (event: KeyboardEvent | MouseEvent<HTMLElement>) => {
const mid = chat.currentEditingMessage.getMID();
if (mid) {
event.preventDefault();
event.stopPropagation();
// NOTE: if the message was reset (i.e. content changed), we keep the editing mode on
const reset = await chat.currentEditingMessage.reset();
if (!reset) {
await chat.currentEditingMessage.cancel();
await chat.currentEditingMessage.stop();
}
popup.clear();
}
};
const keyboardEventHandler = useStableCallback((event: KeyboardEvent) => {
const { which: keyCode } = event;
const input = event.target as HTMLTextAreaElement;
const isSubmitKey = keyCode === keyCodes.CARRIAGE_RETURN || keyCode === keyCodes.NEW_LINE;
if (isSubmitKey) {
const withModifier = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
const isSending = (sendOnEnter && !withModifier) || (!sendOnEnter && withModifier);
event.preventDefault();
if (!isSending) {
chat.composer?.insertNewLine();
return false;
}
handleSendMessage();
return false;
}
if (chat.composer && handleFormattingShortcut(event, [...formattingButtons], chat.composer)) {
return;
}
if (event.shiftKey || event.ctrlKey || event.metaKey) {
return;
}
switch (event.key) {
case 'Escape': {
closeEditing(event);
if (!input.value.trim()) onEscape?.();
return;
}
case 'ArrowUp': {
if (input.selectionEnd === 0) {
event.preventDefault();
event.stopPropagation();
onNavigateToPreviousMessage?.();
if (event.altKey) {
input.setSelectionRange(0, 0);
}
}
return;
}
case 'ArrowDown': {
if (input.selectionEnd === input.value.length) {
event.preventDefault();
event.stopPropagation();
onNavigateToNextMessage?.();
if (event.altKey) {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
}
onTyping?.();
});
const isEditing = useSyncExternalStore(chat.composer?.editing.subscribe ?? emptySubscribe, chat.composer?.editing.get ?? getEmptyFalse);
const isRecordingAudio = useSyncExternalStore(
chat.composer?.recording.subscribe ?? emptySubscribe,
chat.composer?.recording.get ?? getEmptyFalse,
);
const isMicrophoneDenied = useSyncExternalStore(
chat.composer?.isMicrophoneDenied.subscribe ?? emptySubscribe,
chat.composer?.isMicrophoneDenied.get ?? getEmptyFalse,
);
const isRecordingVideo = useSyncExternalStore(
chat.composer?.recordingVideo.subscribe ?? emptySubscribe,
chat.composer?.recordingVideo.get ?? getEmptyFalse,
);
const formatters = useSyncExternalStore(
chat.composer?.formatters.subscribe ?? emptySubscribe,
chat.composer?.formatters.get ?? getEmptyArray,
);
const isRecording = isRecordingAudio || isRecordingVideo;
const { autoGrowRef, textAreaStyle } = useAutoGrow(textareaRef, isRecordingAudio);
const federationMatrixEnabled = useIsFederationEnabled();
// canSendMessage directives read from the Subscriptions store, so subscribe to it to re-run on changes
// (e.g. user joins/leaves the room). room and federationMatrixEnabled are already React-reactive.
const subscribeSubscriptions = useCallback((onStoreChange: () => void) => Subscriptions.use.subscribe(onStoreChange), []);
const canSend = useSyncExternalStore(subscribeSubscriptions, () => {
if (!room.t) {
return false;
}
if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) {
return false;
}
if (isRoomFederated(room)) {
// we are dropping the non native federation for now
if (!isRoomNativeFederated(room)) {
return false;
}
return federationMatrixEnabled;
}
return true;
});
const sizes = useContentBoxSize(textareaRef);
const format = useFormatDateAndTime();
const joinMutation = useMutation({
mutationFn: async () => onJoin?.(),
});
const handlePaste = useStableCallback((event: ClipboardEvent<HTMLTextAreaElement>) => {
const { clipboardData } = event;
if (!clipboardData) {
return;
}
const items = Array.from(clipboardData.items);
if (items.some(({ kind, type }) => kind === 'string' && type === 'text/plain')) {
return;
}
const files = items
.filter((item) => item.kind === 'file' && item.type.indexOf('image/') !== -1)
.map((item) => {
const fileItem = item.getAsFile();
if (!fileItem) {
return;
}
const imageExtension = fileItem ? getImageExtensionFromMime(fileItem.type) : undefined;
const extension = imageExtension ? `.${imageExtension}` : '';
Object.defineProperty(fileItem, 'name', {
writable: true,
value: `Clipboard - ${format(new Date())}${extension}`,
});
return fileItem;
})
.filter((file): file is File => !!file);
if (files.length) {
event.preventDefault();
handleUploadFiles?.(files);
}
});
const popupOptions = useComposerPopupOptions();
const popup = useComposerBoxPopup(popupOptions);
const keyDownHandlerCallbackRef = useSafeRefCallback(
useCallback(
(node: HTMLTextAreaElement) => {
const eventHandler = (e: KeyboardEvent) => keyboardEventHandler(e);
node.addEventListener('keydown', eventHandler);
return () => {
node.removeEventListener('keydown', eventHandler);
};
},
[keyboardEventHandler],
),
);
const beforeInputHandlerCallbackRef = useSafeRefCallback(
useCallback(
(node: HTMLTextAreaElement) => {
const eventHandler = (e: Event) => handleSelectionWrapping(e as InputEvent, chat);
node.addEventListener('beforeinput', eventHandler);
return () => {
node.removeEventListener('beforeinput', eventHandler);
};
},
[chat],
),
);
const mergedRefs = useMessageComposerMergedRefs(
popup.callbackRef,
textareaRef,
autoGrowRef,
callbackRef,
autofocusRef,
keyDownHandlerCallbackRef,
beforeInputHandlerCallbackRef,
);
const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option);
return (
<MessageBoxBase
rid={room._id}
tmid={tmid}
composer={chat.composer}
messageComposerRef={messageComposerRef}
popup={popup}
shouldPopupPreview={shouldPopupPreview}
isEditing={isEditing}
isRecording={isRecording}
isRecordingAudio={isRecordingAudio}
isRecordingVideo={isRecordingVideo}
isMicrophoneDenied={isMicrophoneDenied}
formatters={formatters}
canSend={canSend}
useEmojis={useEmojis}
sendEnabled={canSend && !isUploading && !isProcessingUploads && (typing || isEditing || hasUploads)}
sendActive={typing || isEditing || hasUploads}
inlineSize={sizes.inlineSize}
e2eEnabled={e2eEnabled}
unencryptedMessagesAllowed={unencryptedMessagesAllowed}
isMobile={isMobile}
joinPending={joinMutation.isPending}
onEmojiClick={handleOpenEmojiPicker}
onSend={handleSendMessage}
onJoin={onJoin}
closeEditing={closeEditing}
input={
<MessageComposerInputExpandable
dimensions={sizes}
ref={mergedRefs}
aria-label={composerPlaceholder}
name='msg'
disabled={isRecording || !canSend || isProcessingUploads}
onChange={setTyping}
style={textAreaStyle}
placeholder={composerPlaceholder}
onPaste={handlePaste}
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
/>
}
files={<MessageComposerFiles />}
/>
);
};
export default memo(MessageBox);

View File

@@ -0,0 +1,168 @@
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import { isTruthy } from '@rocket.chat/tools';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import { useAudioMessageAction } from './hooks/useAudioMessageAction';
import { useCreateDiscussionAction } from './hooks/useCreateDiscussionAction';
import { useFileUploadAction } from './hooks/useFileUploadAction';
import { useShareLocationAction } from './hooks/useShareLocationAction';
import { useTimestampAction } from './hooks/useTimestampAction';
import { useVideoMessageAction } from './hooks/useVideoMessageAction';
import { useWebdavActions } from './hooks/useWebdavActions';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useMessageboxAppsActionButtons';
import { messageBox } from '../../../../../lib/messageBox';
import { useChat } from '../../../contexts/ChatContext';
import { useRoom } from '../../../contexts/RoomContext';
export type MessageBoxActionsToolbarProps = {
canSend: boolean;
isMicrophoneDenied: boolean;
variant: 'small' | 'large';
isRecording: boolean;
rid: IRoom['_id'];
tmid?: IMessage['_id'];
isEditing: boolean;
};
const isHidden = (hiddenActions: Array<string>, action: GenericMenuItemProps) => {
if (!action) {
return true;
}
return hiddenActions.includes(action.id);
};
const MessageBoxActionsToolbar = ({
canSend,
isRecording,
rid,
tmid,
variant = 'large',
isMicrophoneDenied,
isEditing = false,
}: MessageBoxActionsToolbarProps) => {
const t = useTranslation();
const chatContext = useChat();
if (!chatContext) {
throw new Error('useChat must be used within a ChatProvider');
}
const room = useRoom();
const disableBasicActions = !canSend || isRecording || isEditing;
const audioMessageAction = useAudioMessageAction(disableBasicActions || isMicrophoneDenied, isMicrophoneDenied);
const videoMessageAction = useVideoMessageAction(disableBasicActions);
const fileUploadAction = useFileUploadAction(disableBasicActions);
const webdavActions = useWebdavActions(disableBasicActions);
const createDiscussionAction = useCreateDiscussionAction(disableBasicActions, room);
const shareLocationAction = useShareLocationAction(disableBasicActions, room, tmid);
const timestampAction = useTimestampAction(!canSend || isRecording, chatContext.composer);
const apps = useMessageboxAppsActionButtons();
const { composerToolbox: hiddenActions } = useLayoutHiddenActions();
const allActions = {
...(!isHidden(hiddenActions, audioMessageAction) && { audioMessageAction }),
...(!isHidden(hiddenActions, videoMessageAction) && { videoMessageAction }),
...(!isHidden(hiddenActions, fileUploadAction) && { fileUploadAction }),
...(!isHidden(hiddenActions, createDiscussionAction) && { createDiscussionAction }),
...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }),
...(timestampAction && !isHidden(hiddenActions, timestampAction) && { timestampAction }),
...(!hiddenActions.includes('webdav-add') && webdavActions && { webdavActions }),
};
const featured = [];
const createNew = [];
const share = [];
const insert = [];
createNew.push(allActions.createDiscussionAction);
if (allActions.timestampAction) {
insert.push(allActions.timestampAction);
}
if (variant === 'small') {
featured.push(allActions.audioMessageAction, allActions.fileUploadAction);
createNew.push(allActions.videoMessageAction);
} else {
featured.push(allActions.audioMessageAction, allActions.videoMessageAction, allActions.fileUploadAction);
}
if (allActions.webdavActions) {
createNew.push(...allActions.webdavActions);
}
share.push(allActions.shareLocationAction);
const groups = {
...(apps.isSuccess &&
apps.data.length > 0 && {
Apps: apps.data,
}),
...messageBox.actions.get(),
};
const messageBoxActions = Object.entries(groups).map(([name, group]) => {
const items: GenericMenuItemProps[] = group
.filter((item) => !hiddenActions.includes(item.id))
.map((item) => ({
id: item.id,
icon: item.icon,
content: t(item.label),
onClick: (event?: MouseEvent<HTMLElement>) =>
item.action({
rid,
tmid,
event: event as unknown as Event,
chat: chatContext,
}),
gap: Boolean(!item.icon),
disabled: disableBasicActions,
}));
return {
title: t(name as TranslationKey),
items: items || [],
};
});
const createNewFiltered = createNew.filter(isTruthy);
const shareFiltered = share.filter(isTruthy);
const insertFiltered = insert.filter(isTruthy);
const renderAction = ({ id, icon, content, disabled, onClick }: GenericMenuItemProps) => {
if (!icon) {
return;
}
return <MessageComposerAction key={id} icon={icon} data-qa-id={id} title={content as string} disabled={disabled} onClick={onClick} />;
};
return (
<>
<MessageComposerActionsDivider />
{featured.map((action) => action && renderAction(action))}
<GenericMenu
disabled={isRecording || !canSend}
data-qa-id='menu-more-actions'
detached
icon='plus'
sections={[
{ title: t('Create_new'), items: createNewFiltered },
{ title: t('Share'), items: shareFiltered },
...(insertFiltered.length > 0 ? [{ title: t('Insert'), items: insertFiltered }] : []),
...messageBoxActions,
]}
title={t('More_actions')}
/>
</>
);
};
export default memo(MessageBoxActionsToolbar);

View File

@@ -0,0 +1,64 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo } from 'react';
import { AudioRecorder } from '../../../../../../lib/AudioRecorder';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
const audioRecorder = new AudioRecorder();
export const useAudioMessageAction = (disabled: boolean, isMicrophoneDenied: boolean): GenericMenuItemProps => {
const isFileUploadEnabled = useSetting('FileUpload_Enabled', true);
const isAudioRecorderEnabled = useSetting('Message_AudioRecorderEnabled', true);
const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList', '');
const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList', '');
const [isPermissionDenied] = useMediaPermissions('microphone');
const isAllowed = useMemo(
() =>
Boolean(
audioRecorder.isSupported() &&
!isMicrophoneDenied &&
isFileUploadEnabled &&
isAudioRecorderEnabled &&
!fileUploadMediaTypeBlackList?.match(/audio\/mp3|audio\/\*/i) &&
(!fileUploadMediaTypeWhiteList || fileUploadMediaTypeWhiteList.match(/audio\/mp3|audio\/\*/i)),
),
[fileUploadMediaTypeBlackList, fileUploadMediaTypeWhiteList, isAudioRecorderEnabled, isFileUploadEnabled, isMicrophoneDenied],
);
const getMediaActionTitle = useMediaActionTitle('audio', isPermissionDenied, isFileUploadEnabled, isAudioRecorderEnabled, isAllowed);
const chat = useChat();
const stopRecording = useStableCallback(() => {
chat?.action.stop('recording');
chat?.composer?.setRecordingMode(false);
});
const setMicrophoneDenied = useStableCallback((isDenied: boolean) => {
if (isDenied) {
stopRecording();
}
chat?.composer?.setIsMicrophoneDenied(isDenied);
});
useEffect(() => {
setMicrophoneDenied(isPermissionDenied);
}, [setMicrophoneDenied, isPermissionDenied]);
const handleRecordButtonClick = () => chat?.composer?.setRecordingMode(true);
return {
id: 'audio-message',
content: getMediaActionTitle,
icon: 'mic',
disabled: !isAllowed || Boolean(disabled),
onClick: handleRecordButtonClick,
};
};

View File

@@ -0,0 +1,34 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useSetting, usePermission, useSetModal } from '@rocket.chat/ui-contexts';
import CreateDiscussion from '../../../../../../components/CreateDiscussion';
export const useCreateDiscussionAction = (disabled: boolean, room?: IRoom): GenericMenuItemProps => {
const t = useTranslation();
const setModal = useSetModal();
if (!room) {
throw new Error('Invalid room');
}
const handleCreateDiscussion = () =>
setModal(
<CreateDiscussion onClose={() => setModal(null)} defaultParentRoom={room?.prid || room?._id} encryptedParentRoom={room?.encrypted} />,
);
const discussionEnabled = useSetting('Discussion_enabled', true);
const canStartDiscussion = usePermission('start-discussion', room._id);
const canSstartDiscussionOtherUser = usePermission('start-discussion-other-user', room._id);
const allowDiscussion = room && discussionEnabled && !isRoomFederated(room) && (canStartDiscussion || canSstartDiscussionOtherUser);
return {
id: 'create-discussion',
content: t('Discussion'),
icon: 'discussion',
disabled: !allowDiscussion || disabled,
onClick: handleCreateDiscussion,
};
};

View File

@@ -0,0 +1,52 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useFileInput } from '../../../../../../hooks/useFileInput';
import { useChat } from '../../../../contexts/ChatContext';
const fileInputProps = { type: 'file', multiple: true };
export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => {
const { t } = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled', true);
const fileInputRef = useFileInput(fileInputProps);
const chat = useChat();
useEffect(() => {
const resetFileInput = () => {
if (!fileInputRef?.current) {
return;
}
fileInputRef.current.value = '';
};
const handleUploadChange = async () => {
const { getMimeType } = await import('../../../../../../../app/utils/lib/mimeTypes');
const filesToUpload = Array.from(fileInputRef?.current?.files ?? []).map((file) => {
Object.defineProperty(file, 'type', {
value: getMimeType(file.type, file.name),
});
return file;
});
chat?.flows.uploadFiles({ files: filesToUpload, resetFileInput });
};
fileInputRef.current?.addEventListener('change', handleUploadChange);
return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange);
}, [chat, fileInputRef]);
const handleUpload = () => {
fileInputRef?.current?.click();
};
return {
id: 'file-upload',
content: t('Upload_file'),
icon: 'clip',
onClick: handleUpload,
disabled: !fileUploadEnabled || disabled,
};
};

View File

@@ -0,0 +1,33 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting, useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal';
export const useShareLocationAction = (disabled: boolean, room?: IRoom, tmid?: IMessage['tmid']): GenericMenuItemProps => {
if (!room) {
throw new Error('Invalid room');
}
const { t } = useTranslation();
const setModal = useSetModal();
const isMapViewEnabled = useSetting('MapView_Enabled') === true;
const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition);
const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', '');
const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length;
const handleShareLocation = () => setModal(<ShareLocationModal rid={room._id} tmid={tmid} onClose={() => setModal(null)} />);
const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room);
return {
id: 'share-location',
content: t('Location'),
icon: 'map-pin',
onClick: handleShareLocation,
disabled: !allowGeolocation || disabled,
};
};

View File

@@ -0,0 +1,27 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { TimestampPickerModal } from '../../../../../../components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal';
import type { ComposerAPI } from '../../../../../../lib/chats/ChatAPI';
export const useTimestampAction = (disabled: boolean, composer: ComposerAPI | undefined): GenericMenuItemProps | undefined => {
const setModal = useSetModal();
const { t } = useTranslation();
const handleClick = () => {
if (!composer) {
return;
}
setModal(<TimestampPickerModal onClose={() => setModal(null)} composer={composer} />);
};
return {
id: 'timestamp',
icon: 'clock',
content: t('Timestamp'),
onClick: handleClick,
disabled,
};
};

View File

@@ -0,0 +1,62 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo } from 'react';
import { VideoRecorder } from '../../../../../../lib/videoRecorder';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
export const useVideoMessageAction = (disabled: boolean): GenericMenuItemProps => {
const isFileUploadEnabled = useSetting('FileUpload_Enabled', true);
const isVideoRecorderEnabled = useSetting('Message_VideoRecorderEnabled', true);
const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList', 'image/svg+xml');
const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList', '');
const [isPermissionDenied, setIsPermissionDenied] = useMediaPermissions('camera');
const isAllowed = useMemo(
() =>
Boolean(
!isPermissionDenied &&
navigator.mediaDevices &&
window.MediaRecorder &&
isFileUploadEnabled &&
isVideoRecorderEnabled &&
!fileUploadMediaTypeBlackList?.match(/video\/webm|video\/\*/i) &&
(!fileUploadMediaTypeWhiteList || fileUploadMediaTypeWhiteList.match(/video\/webm|video\/\*/i)) &&
Boolean(VideoRecorder.getSupportedMimeTypes()),
),
[fileUploadMediaTypeBlackList, fileUploadMediaTypeWhiteList, isFileUploadEnabled, isPermissionDenied, isVideoRecorderEnabled],
);
const getMediaActionTitle = useMediaActionTitle('video', isPermissionDenied, isFileUploadEnabled, isVideoRecorderEnabled, isAllowed);
const chat = useChat();
const handleOpenVideoMessage = () => {
if (!chat?.composer?.recordingVideo.get()) {
chat?.composer?.setRecordingVideo(true);
}
};
const handleDenyVideo = useStableCallback((isDenied: boolean) => {
if (isDenied) {
chat?.composer?.setRecordingVideo(false);
}
setIsPermissionDenied(isDenied);
});
useEffect(() => {
handleDenyVideo(isPermissionDenied);
}, [handleDenyVideo, isPermissionDenied]);
return {
id: 'video-message',
content: getMediaActionTitle,
icon: 'video-message',
disabled: !isAllowed || Boolean(disabled),
onClick: handleOpenVideoMessage,
};
};

View File

@@ -0,0 +1,45 @@
import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useWebDAVAccountIntegrationsQuery } from '../../../../../../hooks/webdav/useWebDAVAccountIntegrationsQuery';
import { useChat } from '../../../../contexts/ChatContext';
import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal';
import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal';
export const useWebdavActions = (disabled: boolean): GenericMenuItemProps[] => {
const enabled = useSetting('Webdav_Integration_Enabled', false);
const { isSuccess, data } = useWebDAVAccountIntegrationsQuery({ enabled });
const chat = useChat();
const { t } = useTranslation();
const setModal = useSetModal();
const handleAddWebDav = () => setModal(<AddWebdavAccountModal onClose={() => setModal(null)} onConfirm={() => setModal(null)} />);
const handleUpload = async (file: File) => chat?.flows.uploadFiles({ files: [file] });
const handleOpenWebdav = (account: IWebdavAccountIntegration) =>
setModal(<WebdavFilePickerModal account={account} onUpload={handleUpload} onClose={() => setModal(null)} />);
return [
{
id: 'webdav-add',
content: t('Add_Server'),
icon: 'cloud-plus',
disabled: !isSuccess,
onClick: handleAddWebDav,
},
...(isSuccess
? data.map((account) => ({
id: account._id,
content: account.name,
icon: 'cloud-plus' as const,
disabled,
onClick: () => handleOpenWebdav(account),
}))
: []),
];
};

View File

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

View File

@@ -0,0 +1,184 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import {
MessageComposerAction,
MessageComposerToolbarActions,
MessageComposer,
MessageComposerToolbar,
MessageComposerActionsDivider,
MessageComposerToolbarSubmit,
MessageComposerButton,
} from '@rocket.chat/ui-composer';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { MouseEvent, ReactNode, RefObject } from 'react';
import MessageBoxActionsToolbar from './MessageBoxActionsToolbar';
import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar';
import MessageBoxHint from './MessageBoxHint';
import MessageBoxReplies from './MessageBoxReplies';
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../lib/messageBoxFormatting';
import AudioMessageRecorder from '../../../composer/AudioMessageRecorder';
import VideoMessageRecorder from '../../../composer/VideoMessageRecorder';
import ComposerBoxPopup from '../ComposerBoxPopup';
import ComposerBoxPopupPreview from '../ComposerBoxPopupPreview';
import ComposerUserActionIndicator from '../ComposerUserActionIndicator';
import type { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
type MessageBoxBaseProps = {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
input: ReactNode;
hint?: ReactNode;
files?: ReactNode;
composer?: ComposerAPI;
messageComposerRef: RefObject<HTMLElement | null>;
popup: ReturnType<typeof useComposerBoxPopup>;
shouldPopupPreview?: boolean;
isEditing: boolean;
isRecording: boolean;
isRecordingAudio: boolean;
isRecordingVideo: boolean;
isMicrophoneDenied: boolean;
formatters: FormattingButton[];
canSend: boolean;
useEmojis?: boolean;
sendEnabled: boolean;
sendActive: boolean;
inlineSize: number;
e2eEnabled: boolean;
unencryptedMessagesAllowed: boolean;
isMobile: boolean;
joinPending: boolean;
onEmojiClick: (e: MouseEvent<HTMLElement>) => void;
onSend: () => void;
onJoin?: () => Promise<void>;
closeEditing: (event: MouseEvent<HTMLElement>) => void;
};
const MessageBoxBase = ({
rid,
tmid,
input,
hint,
files,
composer,
messageComposerRef,
popup,
shouldPopupPreview,
isEditing,
isRecording,
isRecordingAudio,
isRecordingVideo,
isMicrophoneDenied,
formatters,
canSend,
useEmojis,
sendEnabled,
sendActive,
inlineSize,
e2eEnabled,
unencryptedMessagesAllowed,
isMobile,
joinPending,
onEmojiClick,
onSend,
onJoin,
closeEditing,
}: MessageBoxBaseProps) => {
const t = useTranslation();
const variant = inlineSize < 480 ? 'small' : 'large';
return (
<>
{composer?.quotedMessages && <MessageBoxReplies />}
{shouldPopupPreview && popup.option && (
<ComposerBoxPopup
select={popup.select}
items={popup.items}
focused={popup.focused}
title={popup.option.title}
renderItem={popup.option.renderItem}
/>
)}
{/*
SlashCommand Preview popup works in a weird way
There is only one trigger for all the commands: "/"
After that we need to the slashcommand list and check if the command exists and provide the preview
if not the query is `suspend` which means the slashcommand is not found or doesn't have a preview
*/}
{popup.option?.preview && (
<ComposerBoxPopupPreview
select={popup.select}
items={popup.items as any}
focused={popup.focused as any}
title={popup.option.title}
renderItem={popup.option.renderItem}
ref={popup.commandsRef}
rid={rid}
tmid={tmid}
suspended={popup.suspended}
/>
)}
{hint}
<MessageBoxHint
isEditing={isEditing}
e2eEnabled={e2eEnabled}
unencryptedMessagesAllowed={unencryptedMessagesAllowed}
isMobile={isMobile}
/>
{isRecordingVideo && <VideoMessageRecorder reference={messageComposerRef} rid={rid} tmid={tmid} />}
<MessageComposer ref={messageComposerRef} variant={isEditing ? 'editing' : undefined}>
{isRecordingAudio && <AudioMessageRecorder rid={rid} isMicrophoneDenied={isMicrophoneDenied} />}
{input}
{files}
<MessageComposerToolbar>
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
<MessageComposerAction
icon='emoji'
disabled={!useEmojis || isRecording || !canSend}
onClick={onEmojiClick}
title={t('Emoji')}
/>
<MessageComposerActionsDivider />
{composer && formatters.length > 0 && (
<MessageBoxFormattingToolbar composer={composer} variant={variant} items={formatters} disabled={isRecording || !canSend} />
)}
<MessageBoxActionsToolbar
canSend={canSend}
isMicrophoneDenied={isMicrophoneDenied}
rid={rid}
tmid={tmid}
isRecording={isRecording}
variant={variant}
isEditing={isEditing}
/>
</MessageComposerToolbarActions>
<MessageComposerToolbarSubmit>
{!canSend && (
<MessageComposerButton primary onClick={onJoin} loading={joinPending}>
{t('Join')}
</MessageComposerButton>
)}
{canSend && (
<>
{isEditing && <MessageComposerButton onClick={closeEditing}>{t('Cancel')}</MessageComposerButton>}
<MessageComposerAction
aria-label={t('Send')}
icon='send'
disabled={!sendEnabled}
onClick={onSend}
secondary={sendActive}
info={sendActive}
/>
</>
)}
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
<ComposerUserActionIndicator rid={rid} tmid={tmid} />
</>
);
};
export default MessageBoxBase;

View File

@@ -0,0 +1,42 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI';
import { isPromptButton, type FormattingButton } from '../../../../../lib/messageBoxFormatting';
export type FormattingToolbarDropdownProps = {
composer: ComposerAPI;
items: FormattingButton[];
disabled: boolean;
};
const FormattingToolbarDropdown = ({ composer, items, disabled }: FormattingToolbarDropdownProps) => {
const { t } = useTranslation();
const formattingItems: GenericMenuItemProps[] = items.map((formatter) => {
const handleFormattingAction = () => {
if ('link' in formatter) {
window.open(formatter.link, '_blank', 'rel=noreferrer noopener');
return;
}
if (isPromptButton(formatter)) {
return formatter.prompt(composer);
}
composer.wrapSelection(formatter.pattern);
};
return {
id: `formatter-${formatter.label}`,
content: t(formatter.label),
icon: 'icon' in formatter ? formatter.icon : 'link',
onClick: () => handleFormattingAction(),
};
});
const sections = [{ title: t('Message_Formatting_toolbox'), items: formattingItems }];
return <GenericMenu title={t('Message_Formatting_toolbox')} disabled={disabled} detached icon='meatballs' sections={sections} />;
};
export default FormattingToolbarDropdown;

View File

@@ -0,0 +1,75 @@
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FormattingToolbarDropdown from './FormattingToolbarDropdown';
import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../../lib/messageBoxFormatting';
import { isPromptButton } from '../../../../../lib/messageBoxFormatting';
export type MessageBoxFormattingToolbarProps = {
composer: ComposerAPI;
variant?: 'small' | 'large';
items: FormattingButton[];
disabled: boolean;
};
const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disabled }: MessageBoxFormattingToolbarProps) => {
const { t } = useTranslation();
if (variant === 'small') {
const collapsedItems = [...items];
const featuredFormatter = collapsedItems.splice(0, 1)[0];
return (
<>
{'icon' in featuredFormatter && (
<MessageComposerAction
onClick={() =>
isPromptButton(featuredFormatter) ? featuredFormatter.prompt(composer) : composer.wrapSelection(featuredFormatter.pattern)
}
icon={featuredFormatter.icon}
title={t(featuredFormatter.label)}
disabled={disabled}
/>
)}
<FormattingToolbarDropdown composer={composer} items={collapsedItems} disabled={disabled} />
</>
);
}
return (
<>
{items.map((formatter) =>
'icon' in formatter ? (
<MessageComposerAction
disabled={disabled}
icon={formatter.icon}
key={formatter.label}
data-id={formatter.label}
title={t(formatter.label)}
onClick={(): void => {
if (isPromptButton(formatter)) {
formatter.prompt(composer);
return;
}
if ('link' in formatter) {
window.open(formatter.link, '_blank', 'rel=noreferrer noopener');
return;
}
composer.wrapSelection(formatter.pattern);
}}
/>
) : (
<span key={formatter.label} {...(disabled && { style: { pointerEvents: 'none' } })} title={formatter.label}>
<a href={formatter.link} target='_blank' rel='noopener noreferrer'>
{formatter.text()}
</a>
</span>
),
)}
</>
);
};
export default memo(MessageBoxFormattingToolbar);

View File

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

View File

@@ -0,0 +1,106 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import MessageBoxHint from './MessageBoxHint';
import { useRoom } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
jest.mock('../../hooks/useE2EERoomState', () => ({
useE2EERoomState: jest.fn(),
}));
jest.mock('../../contexts/RoomContext', () => ({
useRoom: jest.fn(),
}));
const renderOptions = {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Editing_message: 'Editing message',
Editing_message_hint: '<strong>esc</strong> to cancel · <strong>enter</strong> to save',
This_room_is_read_only: 'This room is read only',
E2EE_Composer_Unencrypted_Message: "You're sending an unencrypted message",
})
.build(),
};
describe('MessageBoxHint', () => {
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: false });
(useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS');
});
describe('Editing message', () => {
it('renders hint text when isEditing is true', () => {
render(<MessageBoxHint isEditing={true} isMobile={false} />, renderOptions);
expect(screen.getByText('Editing message')).toBeInTheDocument();
expect(screen.getByText('Editing message')).toBeInTheDocument();
});
it('renders helpText when isEditing is true and it is not mobile', () => {
render(<MessageBoxHint isEditing={true} isMobile={false} />, renderOptions);
expect(screen.getByText(/to save/)).toBeInTheDocument();
});
it('renders hint without helpText when isEditing is true and it is mobile', () => {
render(<MessageBoxHint isEditing={true} isMobile={true} />, renderOptions);
expect(screen.queryByText(/to save/)).not.toBeInTheDocument();
});
});
describe('Read only', () => {
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
});
it('renders hint text when Read only is true', () => {
render(<MessageBoxHint />, renderOptions);
expect(screen.getByText('This room is read only')).toBeInTheDocument();
});
});
describe('Unencrypted message', () => {
it('renders hint text when E2EE room with unencrypted messages is true', () => {
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText("You're sending an unencrypted message")).toBeInTheDocument();
});
it('renders "Read only" hint text when E2EE room with unencrypted messages is true', () => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText('This room is read only')).toBeInTheDocument();
});
it('renders "Editing message" hint text when isEditing is truem, E2EE is enabled and unencrypted messages is true', () => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
render(<MessageBoxHint isEditing={true} e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText('Editing message')).toBeInTheDocument();
});
it('does not renders hint text when E2ERoomState is READY', () => {
(useE2EERoomState as jest.Mock).mockReturnValue('READY');
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when E2ERoomState is DISABLED', () => {
(useE2EERoomState as jest.Mock).mockReturnValue('DISABLED');
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when unencrypted messages is true and E2EE is disabled', () => {
render(<MessageBoxHint e2eEnabled={false} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when unencrypted messages is false and E2EE is enabled', () => {
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={false} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,58 @@
import { MessageComposerHint } from '@rocket.chat/ui-composer';
import { memo } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useRoom } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
export type MessageBoxHintProps = {
isEditing?: boolean;
e2eEnabled?: boolean;
unencryptedMessagesAllowed?: boolean;
isMobile?: boolean;
};
const MessageBoxHint = ({ isEditing, e2eEnabled, unencryptedMessagesAllowed, isMobile }: MessageBoxHintProps) => {
const room = useRoom();
const isReadOnly = room?.ro || false;
const { t } = useTranslation();
const e2eRoomState = useE2EERoomState(room._id);
const isUnencryptedHintVisible =
e2eEnabled &&
unencryptedMessagesAllowed &&
e2eRoomState &&
e2eRoomState !== 'READY' &&
e2eRoomState !== 'DISABLED' &&
!isEditing &&
!isReadOnly;
if (!isEditing && !isUnencryptedHintVisible && !isReadOnly) {
return null;
}
const renderHintText = (): string => {
if (isEditing) {
return t('Editing_message');
}
if (isReadOnly) {
return t('This_room_is_read_only');
}
if (isUnencryptedHintVisible) {
return t('E2EE_Composer_Unencrypted_Message');
}
return '';
};
return (
<MessageComposerHint
icon={isEditing ? 'pencil' : undefined}
helperText={isEditing && !isMobile ? <Trans i18nKey='Editing_message_hint' /> : undefined}
>
{renderHintText()}
</MessageComposerHint>
);
};
export default memo(MessageBoxHint);

View File

@@ -0,0 +1,29 @@
import { Box } from '@rocket.chat/fuselage';
import { memo, useSyncExternalStore } from 'react';
import MessageBoxReply from './MessageBoxReply';
import { useChat } from '../../contexts/ChatContext';
const MessageBoxReplies = () => {
const chat = useChat();
if (!chat?.composer?.quotedMessages) {
throw new Error('Chat context not found');
}
const replies = useSyncExternalStore(chat.composer.quotedMessages.subscribe, chat.composer.quotedMessages.get);
if (!replies.length) {
return null;
}
return (
<Box marginBlockEnd={8} position='relative' overflowY='auto' maxHeight='x256'>
{replies.map((reply) => (
<MessageBoxReply key={reply._id} reply={reply} />
))}
</Box>
);
};
export default memo(MessageBoxReplies);

View File

@@ -0,0 +1,58 @@
import type { IMessage, MessageQuoteAttachment } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { IconButton, Box, Margins } from '@rocket.chat/fuselage';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { QuoteAttachment } from '../../../../components/message/content/attachments/QuoteAttachment';
import AttachmentProvider from '../../../../providers/AttachmentProvider';
import { useChat } from '../../contexts/ChatContext';
export type MessageBoxReplyProps = { reply: IMessage };
const MessageBoxReply = ({ reply }: MessageBoxReplyProps) => {
const { t } = useTranslation();
const chat = useChat();
const displayName = useUserDisplayName(reply?.u);
const closeWrapperStyle = css`
position: absolute;
right: 0.5rem;
top: 0.75rem;
`;
return (
<Margins block={4}>
<Box display='flex' position='relative'>
<AttachmentProvider>
<QuoteAttachment
attachment={
{
text: reply.msg,
md: reply.md,
author_name: reply.alias || displayName,
author_icon: `/avatar/${reply.u.username}`,
ts: reply.ts,
attachments: reply?.attachments?.map((obj) => ({ ...obj, collapsed: true })),
collapsed: true,
} as MessageQuoteAttachment
}
/>
</AttachmentProvider>
<Box
className={closeWrapperStyle}
data-mid={reply._id}
onClick={(): void => {
chat?.composer?.dismissQuotedMessage(reply._id);
}}
>
<IconButton mini icon='cross' aria-label={t('Dismiss_quoted_message')} />
</Box>
</Box>
</Margins>
);
};
export default memo(MessageBoxReply);

View File

@@ -0,0 +1,27 @@
import MessageComposerGenericFile from './MessageComposerGenericFile';
import MessageComposerImageFileItem from './MessageComposerImageFile';
import type { Upload } from '../../../../lib/chats/Upload';
import { MAX_FILE_SIZE_PREVIEW } from '../../../../lib/constants';
import { isPreviewableImage } from '../../../../lib/utils/isPreviewableImage';
export type MessageComposerFileItemProps = {
upload: Upload;
onRemove: (id: string) => void;
onEdit: (id: Upload['id'], fileName: string, altText?: string) => void;
onCancel: (id: Upload['id']) => void;
disabled: boolean;
shouldPreview?: boolean;
previewUrl?: string;
};
const MessageComposerFileItem = (props: MessageComposerFileItemProps) => {
const shouldPreview = isPreviewableImage(props.upload.file.type) && !(props.upload.file.size > MAX_FILE_SIZE_PREVIEW);
if (shouldPreview) {
return <MessageComposerImageFileItem {...props} />;
}
return <MessageComposerGenericFile {...props} />;
};
export default MessageComposerFileItem;

View File

@@ -0,0 +1,43 @@
import { MessageComposerFileGroup } from '@rocket.chat/ui-composer';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import MessageComposerFileItem from './MessageComposerFileItem';
import type { Upload } from '../../../../lib/chats/Upload';
import { useFileUpload } from '../../body/hooks/useFileUpload';
const MessageComposerFiles = () => {
const { t } = useTranslation();
const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload();
const handleEdit = useCallback(
(id: Upload['id'], fileName: string, altText?: string) => {
uploadsStore?.editUploadFileName(id, fileName);
if (altText !== undefined) {
uploadsStore?.editUploadAltText(id, altText);
}
},
[uploadsStore],
);
if (!uploadsStore || !hasUploads) {
return null;
}
return (
<MessageComposerFileGroup aria-label={t('Uploads')}>
{uploads.map((upload) => (
<MessageComposerFileItem
key={upload.id}
upload={upload}
onRemove={uploadsStore.removeUpload}
onEdit={handleEdit}
onCancel={uploadsStore.cancel}
disabled={isProcessingUploads}
/>
))}
</MessageComposerFileGroup>
);
};
export default MessageComposerFiles;

View File

@@ -0,0 +1,100 @@
import { IconButton } from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { MessageComposerFile, MessageComposerFileError, MessageComposerFileLoader } from '@rocket.chat/ui-composer';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageComposerFileItemProps } from './MessageComposerFileItem';
import { getMimeType } from '../../../../../app/utils/lib/mimeTypes';
import { getFileExtension } from '../../../../../lib/utils/getFileExtension';
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
import { formatBytes } from '../../../../lib/utils/formatBytes';
import { useChat } from '../../contexts/ChatContext';
import FileUploadModal from '../../modals/FileUploadModal';
const MessageComposerGenericFile = ({
upload,
onRemove,
onEdit,
onCancel,
shouldPreview,
previewUrl,
disabled,
...props
}: MessageComposerFileItemProps) => {
const { t } = useTranslation();
const chat = useChat();
const [isActive, setIsActive] = useState(false);
const setModal = useSetModal();
const fileSize = formatBytes(upload.file.size, 2);
const fileExtension = getMimeType(upload.file.type, upload.file.name);
const isLoading = !upload.url && !upload.error;
const handleOpenFilePreview = () => {
if (isLoading || upload.error) {
return;
}
setModal(
<FileUploadModal
onSubmit={(name, altText) => {
onEdit(upload.id, name, altText);
setModal(null);
chat?.composer?.focus();
}}
fileName={upload.file.name}
fileAltText={upload.altText}
file={upload.file}
onClose={() => setModal(null)}
/>,
);
};
const dismissAction = isLoading ? () => onCancel(upload.id) : () => onRemove(upload.id);
const handleDismiss = usePreventPropagation(dismissAction);
const buttonProps = useButtonPattern(handleDismiss);
const actionIcon =
isLoading && !isActive ? (
<MessageComposerFileLoader />
) : (
<IconButton {...buttonProps} aria-label={isLoading ? t('Cancel') : t('Remove')} mini icon='cross' />
);
if (upload.error) {
return (
<MessageComposerFileError
fileTitle={upload.file.name}
fileFormat={getFileExtension(upload.file.name)}
error={upload.error}
actionIcon={actionIcon}
onClick={handleOpenFilePreview}
/>
);
}
return (
<MessageComposerFile
aria-label={upload.file.name}
onClick={handleOpenFilePreview}
onPointerLeave={() => setIsActive(false)}
onPointerEnter={() => setIsActive(true)}
onFocus={() => setIsActive(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setIsActive(false)}
fileTitle={upload.file.name}
fileSubtitle={`${fileSize} - ${fileExtension}`}
previewUrl={shouldPreview ? previewUrl : undefined}
alt={upload.altText}
fileFormat={getFileExtension(upload.file.name)}
showPreview={shouldPreview}
actionIcon={actionIcon}
aria-busy={isLoading}
disabled={disabled}
{...props}
/>
);
};
export default MessageComposerGenericFile;

View File

@@ -0,0 +1,11 @@
import type { MessageComposerFileItemProps } from './MessageComposerFileItem';
import MessageComposerGenericFile from './MessageComposerGenericFile';
import { useFileAsDataURL } from '../../hooks/useFileAsDataURL';
const MessageComposerImageFile = ({ upload, ...props }: MessageComposerFileItemProps) => {
const [, url] = useFileAsDataURL(upload.file);
return <MessageComposerGenericFile upload={upload} shouldPreview previewUrl={typeof url === 'string' ? url : undefined} {...props} />;
};
export default MessageComposerImageFile;

View File

@@ -0,0 +1,61 @@
import { createComposerAPI } from './createComposerAPI';
jest.mock('../../../../lib/chats/uploads', () => ({
createUploadsAPI: () => ({}),
}));
const setupComposer = (initialValue: string, cursor: { start: number; end: number }) => {
const input = document.createElement('textarea');
document.body.appendChild(input);
const composer = createComposerAPI(input, jest.fn(), '', Number.MAX_SAFE_INTEGER, { current: null }, { rid: 'GENERAL' });
input.value = initialValue;
input.setSelectionRange(cursor.start, cursor.end);
return { composer, input };
};
describe('ChatMessages Composer API - replaceText', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('should place the cursor right after the mention when inserting at the start of the message', () => {
const { composer, input } = setupComposer('@jhello', { start: 2, end: 2 });
composer.replaceText('@john ', { start: 0, end: 2 });
expect(input.value).toBe('@john hello');
expect(input.selectionStart).toBe('@john '.length);
expect(input.selectionEnd).toBe('@john '.length);
});
it('should place the cursor right after the mention when inserting in the middle of the message', () => {
const { composer, input } = setupComposer('hi @jthere', { start: 5, end: 5 });
composer.replaceText('@john ', { start: 3, end: 5 });
expect(input.value).toBe('hi @john there');
expect(input.selectionStart).toBe('hi @john '.length);
expect(input.selectionEnd).toBe('hi @john '.length);
});
it('should place the cursor right after the mention when inserting at the end of the message', () => {
const { composer, input } = setupComposer('hello @j', { start: 8, end: 8 });
composer.replaceText('@john ', { start: 6, end: 8 });
expect(input.value).toBe('hello @john ');
expect(input.selectionStart).toBe('hello @john '.length);
expect(input.selectionEnd).toBe('hello @john '.length);
});
it('should keep the cursor collapsed right after the inserted text', () => {
const { composer, input } = setupComposer('@jhello', { start: 2, end: 2 });
composer.replaceText('@john ', { start: 0, end: 2 });
expect(input.selectionStart).toBe(input.selectionEnd);
});
});

View File

@@ -0,0 +1,172 @@
import type { RefObject } from 'react';
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import { createComposerAPICore, triggerEvent, type SetText } from '../../../../lib/createComposerAPICore';
import { limitQuoteChain } from '../../../../lib/limitQuoteChain';
export const createComposerAPI = (
input: HTMLTextAreaElement,
persistDraft: (value: string) => void,
initialDraft: string,
quoteChainLimit: number,
composerRef: RefObject<HTMLElement | null>,
{ rid, tmid }: { rid: string; tmid?: string },
): ComposerAPI => {
const focus = (): void => {
input.focus();
};
const setText: SetText = (text, { selection, skipFocus } = {}) => {
!skipFocus && focus();
const { selectionStart, selectionEnd } = input;
const textAreaTxt = input.value;
if (typeof selection === 'function') {
selection = selection({ start: selectionStart, end: selectionEnd });
}
if (selection) {
if (!document.execCommand?.('insertText', false, text)) {
input.value = textAreaTxt.substring(0, selectionStart) + text + textAreaTxt.substring(selectionStart);
!skipFocus && focus();
}
input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length);
}
if (!selection) {
input.value = text;
}
triggerEvent(input, 'input');
triggerEvent(input, 'change');
!skipFocus && focus();
};
const core = createComposerAPICore({
input,
composerRef,
room: { rid, tmid },
initialValue: initialDraft,
save: () => persistDraft(input.value),
setText,
focus,
prepareQuotedMessage: (message) => limitQuoteChain(message, quoteChainLimit),
});
const { insertText } = core;
const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => {
const { selectionEnd = input.value.length, selectionStart = 0 } = input;
const initText = input.value.slice(0, selectionStart);
const selectedText = input.value.slice(selectionStart, selectionEnd);
const finalText = input.value.slice(selectionEnd, input.value.length);
focus();
const startPattern = pattern.slice(0, pattern.indexOf('{{text}}'));
const startPatternFound = input.value.slice(selectionStart - startPattern.length, selectionStart) === startPattern;
if (startPatternFound) {
const endPattern = pattern.slice(pattern.indexOf('{{text}}') + '{{text}}'.length);
const endPatternFound = input.value.slice(selectionEnd, selectionEnd + endPattern.length) === endPattern;
if (endPatternFound) {
insertText(selectedText);
input.selectionStart = selectionStart - startPattern.length;
input.selectionEnd = selectionEnd + endPattern.length;
if (!document.execCommand?.('insertText', false, selectedText)) {
input.value = initText.slice(0, initText.length - startPattern.length) + selectedText + finalText.slice(endPattern.length);
}
input.selectionStart = selectionStart - startPattern.length;
input.selectionEnd = input.selectionStart + selectedText.length;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
return {
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
value: input.value,
};
}
}
if (!document.execCommand?.('insertText', false, pattern.replace('{{text}}', selectedText))) {
input.value = initText + pattern.replace('{{text}}', selectedText) + finalText;
}
input.selectionStart = selectionStart + pattern.indexOf('{{text}}');
input.selectionEnd = input.selectionStart + selectedText.length;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
return {
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
value: input.value,
};
};
// Gets the text that is connected to the cursor and replaces it with the given text
const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => {
// Selects the text that is connected to the cursor
input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length);
const textAreaTxt = input.value;
if (!document.execCommand?.('insertText', false, text)) {
input.value = textAreaTxt.substring(0, selection.start) + text + textAreaTxt.substring(selection.end);
}
const cursorPosition = (selection.start ?? 0) + text.length;
input.selectionStart = cursorPosition;
input.selectionEnd = cursorPosition;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
};
const replyWith = async (text: string): Promise<void> => {
setText(text);
};
return {
...core,
setText,
wrapSelection,
replaceText,
replyWith,
substring: (start: number, end?: number) => {
return input.value.substring(start, end);
},
getCursorPosition: () => {
return input.selectionStart;
},
setCursorToEnd: () => {
input.selectionEnd = input.value.length;
input.selectionStart = input.selectionEnd;
focus();
},
setCursorToStart: () => {
input.selectionStart = 0;
input.selectionEnd = input.selectionStart;
focus();
},
get text(): string {
return input.value;
},
get selection(): { start: number; end: number } {
return {
start: input.selectionStart,
end: input.selectionEnd,
};
},
};
};

View File

@@ -0,0 +1,535 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useDraft } from './useDraft';
const storageKey = (rid: string, tmid?: string) => `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
const seedLocalDraft = (value: string, rid: string, tmid?: string) => {
localStorage.setItem(storageKey(rid, tmid), value);
};
const readLocalDraft = (rid: string, tmid?: string) => localStorage.getItem(storageKey(rid, tmid));
type RenderUseDraftProps = {
serverDraft?: string;
threadExists?: boolean;
};
const renderUseDraft = ({
rid = 'rid',
serverDraft,
tmid,
threadExists,
endpointHandler = jest.fn(() => null),
}: {
rid?: string;
tmid?: string;
endpointHandler?: jest.Mock;
} & RenderUseDraftProps = {}) => {
const { result, rerender } = renderHook<ReturnType<typeof useDraft>, RenderUseDraftProps>(
({ serverDraft, threadExists }) => useDraft(rid, serverDraft, tmid, threadExists),
{
initialProps: { serverDraft, threadExists },
wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(),
},
);
return { result, rerender, endpointHandler };
};
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});
describe('initialValue', () => {
it('should be an empty string when there is no server draft nor local draft', () => {
const { result } = renderUseDraft();
expect(result.current.initialValue).toBe('');
});
it('should restore the local draft when there is no server draft', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft();
expect(result.current.initialValue).toBe('local draft');
});
it('should restore the server draft when provided', () => {
const { result } = renderUseDraft({ serverDraft: 'server draft' });
expect(result.current.initialValue).toBe('server draft');
});
it('should prefer the server draft over the local draft', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft({ serverDraft: 'server draft' });
expect(result.current.initialValue).toBe('server draft');
});
it('should fall back to the local draft when the server draft is an empty string', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft({ serverDraft: '' });
expect(result.current.initialValue).toBe('local draft');
});
it('should restore the thread-scoped local draft when a tmid is provided', () => {
seedLocalDraft('room draft', 'rid');
seedLocalDraft('thread draft', 'rid', 'tmid');
const { result } = renderUseDraft({ tmid: 'tmid' });
expect(result.current.initialValue).toBe('thread draft');
});
it('should not restore the room draft in a thread composer', () => {
seedLocalDraft('room draft', 'rid');
const { result } = renderUseDraft({ tmid: 'tmid' });
expect(result.current.initialValue).toBe('');
});
it('should remain stable across rerenders even if the server draft changes', () => {
const { result, rerender } = renderUseDraft({ serverDraft: 'first' });
rerender({ serverDraft: 'second' });
expect(result.current.initialValue).toBe('first');
});
});
describe('persistLocal', () => {
it('should write the draft to local storage under the room-scoped key', () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
});
expect(readLocalDraft('rid')).toBe('typed message');
});
it('should write the draft to local storage under the thread-scoped key when a tmid is provided', () => {
const { result } = renderUseDraft({ tmid: 'tmid' });
act(() => {
result.current.persistLocal('typed in thread');
});
expect(readLocalDraft('rid', 'tmid')).toBe('typed in thread');
expect(readLocalDraft('rid')).toBe(null);
});
it('should overwrite a previously persisted draft', () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.persistLocal('second');
});
expect(readLocalDraft('rid')).toBe('second');
});
it('should remove the local entry instead of storing an empty string', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('');
});
expect(readLocalDraft('rid')).toBe(null);
});
it('should not call the save draft endpoint', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
});
expect(endpointHandler).not.toHaveBeenCalled();
});
});
describe('flushDraft', () => {
it('should not call the save draft endpoint when no draft was persisted', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not flush a draft restored from local storage that was never re-persisted', () => {
seedLocalDraft('restored draft', 'rid');
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should save the persisted draft to the server', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
});
it('should remove the local copy once the draft is saved to the server', async () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
});
it('should include the tmid when flushing a thread draft', async () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid' });
act(() => {
result.current.persistLocal('typed in thread');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in thread', tmid: 'tmid' });
});
it('should flush only the latest persisted draft', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.persistLocal('second');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'second' });
});
it('should flush an empty draft to clear an existing server draft', async () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
});
it('should not call the endpoint when flushing an empty draft with no existing server draft', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not re-save a restored server draft that was not edited', () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('server draft');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not flush the same draft twice', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
});
it('should flush again after a new draft is persisted', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.flushDraft();
result.current.persistLocal('second');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(2));
expect(endpointHandler).toHaveBeenNthCalledWith(1, { rid: 'rid', draft: 'first' });
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'second' });
});
it('should not save the draft to the server when the thread does not exist', () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('typed in deleted thread');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should keep the draft in local storage when the thread does not exist', () => {
const { result } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('typed in deleted thread');
result.current.flushDraft();
});
expect(readLocalDraft('rid', 'tmid')).toBe('typed in deleted thread');
});
it('should keep discarding drafts persisted after a flush while the thread does not exist', () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('first');
result.current.flushDraft();
result.current.persistLocal('second');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not save the draft when the thread stops existing after mount', () => {
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: true });
act(() => {
result.current.persistLocal('typed before thread deletion');
});
rerender({ threadExists: false });
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should save the draft when the thread starts existing after mount', async () => {
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
rerender({ threadExists: true });
act(() => {
result.current.persistLocal('typed in new thread');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in new thread', tmid: 'tmid' });
});
it('should still save the draft when threadExists is false but there is no tmid', async () => {
const { result, endpointHandler } = renderUseDraft({ threadExists: false });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
});
it('should flush the given value instead of the persisted draft', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('edited message');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'edited message' });
});
it('should clear the server draft when flushed with an empty value', async () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
});
it('should clear the server thread draft when flushed with an empty value', async () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed in thread');
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '', tmid: 'tmid' });
});
it('should remove the local copy once the draft is cleared on the server', async () => {
const { result } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
});
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
});
it('should not call the endpoint when flushing an empty value with no server draft', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
result.current.flushDraft('');
});
expect(endpointHandler).not.toHaveBeenCalled();
expect(readLocalDraft('rid')).toBe(null);
});
it('should remove the local copy when the flushed value already matches the server draft', () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('server draft');
});
expect(endpointHandler).not.toHaveBeenCalled();
expect(readLocalDraft('rid')).toBe(null);
});
it('should keep a draft persisted while the previous save is still in flight', async () => {
let resolveSave: (() => void) | undefined;
const endpointHandler = jest.fn(
() =>
new Promise<null>((resolve) => {
resolveSave = () => resolve(null);
}),
);
const { result } = renderUseDraft({ serverDraft: 'server draft', endpointHandler });
act(() => {
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
act(() => {
result.current.persistLocal('typed while sending');
});
await act(async () => {
resolveSave?.();
});
expect(readLocalDraft('rid')).toBe('typed while sending');
});
it('should keep the local copy when the save fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const endpointHandler = jest.fn(() => Promise.reject(new Error('save failed')) as unknown as null);
const { result } = renderUseDraft({ endpointHandler });
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(warn).toHaveBeenCalledTimes(1);
expect(readLocalDraft('rid')).toBe('typed message');
});
it('should retry a draft whose save failed and drop the local copy once it lands', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const endpointHandler = jest.fn().mockRejectedValueOnce(new Error('save failed')).mockResolvedValueOnce(null);
const { result } = renderUseDraft({ endpointHandler });
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(readLocalDraft('rid')).toBe('typed message');
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(warn).toHaveBeenCalledTimes(1);
expect(endpointHandler).toHaveBeenCalledTimes(2);
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'typed message' });
expect(readLocalDraft('rid')).toBe(null);
});
it('should not flush the persisted draft after it was superseded by an explicit flush', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('sent message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'sent message' });
});
});
describe('referential stability', () => {
it('should keep persistLocal and flushDraft identities across rerenders', () => {
const { result, rerender } = renderUseDraft();
const { persistLocal, flushDraft } = result.current;
rerender({});
expect(result.current.persistLocal).toBe(persistLocal);
expect(result.current.flushDraft).toBe(flushDraft);
});
});

View File

@@ -0,0 +1,80 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useRef } from 'react';
export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => {
const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
const saveDraft = useEndpoint('POST', '/v1/rooms.saveDraft');
const setLocalDraft = useCallback(
(value?: string) => {
if (value) {
localStorage.setItem(storageKey, value);
} else {
localStorage.removeItem(storageKey);
}
},
[storageKey],
);
const initialValueRef = useRef(serverDraft || localStorage.getItem(storageKey) || '');
const draftRef = useRef<string | null>(null);
const threadExistsRef = useRef(threadExists);
const serverValueRef = useRef(serverDraft ?? '');
useEffect(() => {
threadExistsRef.current = threadExists;
}, [threadExists]);
const persistLocal = useCallback(
(value: string) => {
draftRef.current = value;
setLocalDraft(value);
},
[setLocalDraft],
);
const flushDraft = useCallback(
(value?: string) => {
const draft = value ?? draftRef.current;
if (draft === null) {
return;
}
draftRef.current = null;
if (tmid && !threadExistsRef.current && draft) {
return;
}
if (draft === serverValueRef.current) {
setLocalDraft();
return;
}
const previousServerValue = serverValueRef.current;
serverValueRef.current = draft;
void saveDraft({ rid, draft, ...(tmid && { tmid }) })
.then(() => {
if (draftRef.current === null) {
setLocalDraft();
}
})
.catch((error) => {
if (serverValueRef.current === draft) {
serverValueRef.current = previousServerValue;
}
console.warn(error);
});
},
[saveDraft, rid, tmid, setLocalDraft],
);
return {
initialValue: initialValueRef.current,
persistLocal,
flushDraft,
};
};

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const useMediaActionTitle = (
media: 'audio' | 'video',
isPermissionDenied: boolean,
isFileUploadEnabled: boolean,
isMediaEnabled: boolean,
isAllowed: boolean,
) => {
const { t } = useTranslation();
const getMediaActionTitle = useMemo(() => {
if (isPermissionDenied) {
return media === 'audio' ? t('Microphone_access_not_allowed') : t('Camera_access_not_allowed');
}
if (!isFileUploadEnabled) {
return t('File_Upload_Disabled');
}
if (!isMediaEnabled) {
return media === 'audio' ? t('Message_Audio_Recording_Disabled') : t('Message_Video_Recording_Disabled');
}
if (!isAllowed) {
return t('error-not-allowed');
}
return media === 'audio' ? t('Audio_message') : t('Video_message');
}, [media, isPermissionDenied, isFileUploadEnabled, isMediaEnabled, isAllowed, t]);
return getMediaActionTitle;
};

View File

@@ -0,0 +1,59 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type MediaDevices = 'camera' | 'microphone';
const getDeviceKind = (name: MediaDevices): MediaDeviceKind => {
switch (name) {
case 'camera':
return 'videoinput';
case 'microphone':
return 'audioinput';
}
};
export const useMediaPermissions = (name: MediaDevices): [isPermissionDenied: boolean, setIsPermissionDenied: (state: boolean) => void] => {
const queryClient = useQueryClient();
const queryKey = ['media-permissions', name];
const setIsPermissionDenied = useStableCallback((isDenied: boolean) => {
queryClient.setQueryData(queryKey, isDenied);
});
const { data } = useQuery({
queryKey,
queryFn: async () => {
if (navigator.permissions) {
try {
const permissionStatus = await navigator.permissions.query({ name: name as PermissionName });
permissionStatus.onchange = (): void => {
queryClient.setQueryData(queryKey, permissionStatus.state === 'denied');
};
return permissionStatus.state === 'denied';
} catch (error) {
console.warn(error);
}
}
if (!navigator.mediaDevices?.enumerateDevices) {
return true;
}
try {
if (!(await navigator.mediaDevices.enumerateDevices()).some(({ kind }) => kind === getDeviceKind(name))) {
return true;
}
} catch (error) {
console.warn(error);
}
},
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
placeholderData: false,
});
return [Boolean(data), setIsPermissionDenied];
};

View File

@@ -0,0 +1,64 @@
import type { Ref } from 'react';
import { useCallback, useEffect, useRef } from 'react';
/**
* if the user is types outside the message box and its not actually typing in any input field
* then the message box should be focused
* @returns callbackRef to bind the logic to the message box
*/
export const useMessageBoxAutoFocus = (enabled: boolean): Ref<HTMLElement> => {
const ref = useRef<HTMLElement>(undefined);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const { current: node } = ref;
const { target } = e;
if (!target) {
return;
}
if (target === node) {
return;
}
if (!((e.keyCode > 45 && e.keyCode < 91) || e.keyCode === 8)) {
return;
}
if (/input|textarea|select/i.test((target as HTMLElement).tagName)) {
return;
}
if (e.ctrlKey === true || e.metaKey === true) {
return;
}
node?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
}
ref.current = node;
if (!enabled) {
return;
}
if (ref.current) {
ref.current.focus();
}
},
[enabled, ref],
);
};

View File

@@ -0,0 +1,18 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useRoomName } from '../../../../../hooks/useRoomName';
export const useMessageBoxPlaceholder = (placeholder: string, room?: IRoom) => {
if (!room) {
throw new Error('In order to get the placeholder a `room` must be provided');
}
const roomName = useRoomName(room);
if (isDirectMessageRoom(room)) {
return `${placeholder} @${roomName}`;
}
return `${placeholder} #${roomName}`;
};

View File

@@ -0,0 +1,39 @@
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../lib/messageBoxFormatting';
export const emptySubscribe = () => () => undefined;
export const getEmptyFalse = () => false;
const a: any[] = [];
export const getEmptyArray = () => a;
export const isCmdOrCtrlPressed = (event: { metaKey: boolean; ctrlKey: boolean }) => {
const isMacOS = navigator.platform.indexOf('Mac') !== -1;
return (isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey);
};
export const handleFormattingShortcut = (
event: KeyboardEvent,
formattingButtons: FormattingButton[],
composer: ComposerAPI,
preventDefault = false,
) => {
if (!isCmdOrCtrlPressed(event)) {
return false;
}
const key = event.key.toLowerCase();
const formatter = formattingButtons.find((formatter) => 'command' in formatter && formatter.command === key);
if (!formatter || !('pattern' in formatter)) {
return false;
}
if (preventDefault) {
event.preventDefault();
}
composer.wrapSelection(formatter.pattern);
return true;
};

View File

@@ -0,0 +1,191 @@
import { handleSelectionWrapping } from './wrapSelection';
import type { ChatAPI, ComposerAPI } from '../../../../lib/chats/ChatAPI';
const createTextarea = (value: string, selectionStart: number, selectionEnd: number): HTMLTextAreaElement => {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.selectionStart = selectionStart;
textarea.selectionEnd = selectionEnd;
return textarea;
};
const createMockComposer = (text: string): Pick<ComposerAPI, 'text' | 'wrapSelection'> => ({
text,
wrapSelection: jest.fn(() => ({ selectionStart: 0, selectionEnd: 0, value: '' })),
});
const createInputEvent = (textarea: HTMLTextAreaElement, data: string, isComposing = false): InputEvent => {
const event = new InputEvent('input', {
data,
inputType: 'insertText',
isComposing,
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'target', { value: textarea, writable: false });
return event;
};
describe('handleSelectionWrapping', () => {
describe('dead key / IME composition scenarios', () => {
it('should NOT wrap when the selected text is the same character just typed (dead key double-press)', () => {
// Simulates: user presses ' twice on a Brazilian/international keyboard
// After composition ends, the browser has:
// - inserted the character ' into the textarea
// - selected it (selectionStart=0, selectionEnd=1)
// - fired an InputEvent with data="'"
const textarea = createTextarea("'", 0, 1);
const composer = createMockComposer("'");
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when dead key composition produces a character mid-text', () => {
// User is typing "hello'" — the dead key inserts ' and selects it
const textarea = createTextarea("hello'", 5, 6);
const composer = createMockComposer("hello'");
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when backtick dead key is pressed twice', () => {
const textarea = createTextarea('`', 0, 1);
const composer = createMockComposer('`');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '`');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when tilde dead key is pressed twice', () => {
const textarea = createTextarea('˜', 0, 1);
const composer = createMockComposer('˜');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '˜');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
});
describe('legitimate wrapping scenarios', () => {
it('should wrap when user selects text and types a wrapping character', () => {
// User selects "hello" and types '
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith("'{{text}}'");
});
it('should wrap when user selects partial text and types a wrapping character', () => {
// User selects "world" in "hello world" and types *
const textarea = createTextarea('hello world', 6, 11);
const composer = createMockComposer('hello world');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '*');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith('*{{text}}*');
});
it('should wrap with backtick when text is selected', () => {
const textarea = createTextarea('some code here', 5, 9);
const composer = createMockComposer('some code here');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '`');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith('`{{text}}`');
});
});
describe('no-op scenarios', () => {
it('should return false when there is no selection (cursor only)', () => {
const textarea = createTextarea('hello', 3, 3);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should return false when there is no composer', () => {
const textarea = createTextarea('hello', 0, 5);
const chat = {} as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
});
it('should return false for non-wrapping characters', () => {
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, 'a');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should return false when event.data is null', () => {
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = new InputEvent('input', {
data: null,
inputType: 'deleteContentBackward',
bubbles: true,
});
Object.defineProperty(event, 'target', { value: textarea, writable: false });
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,66 @@
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
const wrapSelectionPatterns: Record<string, string> = {
'`': '`{{text}}`',
'"': '"{{text}}"',
"'": "'{{text}}'",
'(': '({{text}})',
'<': '<{{text}}>',
'{': '{{{text}}}',
'[': '[{{text}}]',
'*': '*{{text}}*',
'_': '_{{text}}_',
'~': '~{{text}}~',
'˜': '~{{text}}~',
};
const once = (target: EventTarget, eventName: string, callback: (event: Event) => void) => {
const handleEvent = (e: Event) => {
callback(e);
target.removeEventListener(eventName, handleEvent);
};
target.addEventListener(eventName, handleEvent);
};
export const handleSelectionWrapping = (event: InputEvent, chat: ChatAPI): boolean => {
const { composer } = chat;
if (!composer) {
return false;
}
const input = event.target as HTMLTextAreaElement;
const { selectionStart, selectionEnd } = input;
const testSelection = input.value.slice(selectionStart, selectionEnd);
// if the selection is the same of the data, return false
if (testSelection === event.data) {
return false;
}
if (event.data === chat.composer?.text) {
return false;
}
if (selectionStart === selectionEnd) {
return false;
}
const key = event.data;
if (!key) {
return false;
}
const pattern = wrapSelectionPatterns[key];
if (!pattern) {
return false;
}
const selection = composer.wrapSelection(pattern);
// this is a workaround when we are using MAC
if (event.isComposing) {
once(input, 'input', (event) => {
input.value = selection.value;
input.setSelectionRange(selection.selectionStart, selection.selectionEnd);
event.preventDefault();
});
}
event.preventDefault();
return true;
};