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
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:
@@ -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;
|
||||
55
apps/meteor/client/views/room/composer/ComposerAnonymous.tsx
Normal file
55
apps/meteor/client/views/room/composer/ComposerAnonymous.tsx
Normal 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;
|
||||
14
apps/meteor/client/views/room/composer/ComposerArchived.tsx
Normal file
14
apps/meteor/client/views/room/composer/ComposerArchived.tsx
Normal 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;
|
||||
@@ -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;
|
||||
124
apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx
Normal file
124
apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
87
apps/meteor/client/views/room/composer/ComposerContainer.tsx
Normal file
87
apps/meteor/client/views/room/composer/ComposerContainer.tsx
Normal 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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ComposerFederation';
|
||||
@@ -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;
|
||||
94
apps/meteor/client/views/room/composer/ComposerMessage.tsx
Normal file
94
apps/meteor/client/views/room/composer/ComposerMessage.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ComposerOmnichannel';
|
||||
37
apps/meteor/client/views/room/composer/ComposerReadOnly.tsx
Normal file
37
apps/meteor/client/views/room/composer/ComposerReadOnly.tsx
Normal 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;
|
||||
@@ -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;
|
||||
13
apps/meteor/client/views/room/composer/ComposerSkeleton.tsx
Normal file
13
apps/meteor/client/views/room/composer/ComposerSkeleton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ComposerUserActionIndicator';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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: '' }));
|
||||
});
|
||||
});
|
||||
435
apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
Normal file
435
apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MessageBoxActionsToolbar';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MessageBoxFormattingToolbar';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user