232 lines
No EOL
7.8 KiB
TypeScript
232 lines
No EOL
7.8 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { defineMessages, IntlShape, useIntl } from 'react-intl';
|
|
|
|
import { unblockAccount } from 'soapbox/actions/accounts';
|
|
import { openModal } from 'soapbox/actions/modals';
|
|
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui';
|
|
import { useChatContext } from 'soapbox/contexts/chat-context';
|
|
import UploadButton from 'soapbox/features/compose/components/upload-button';
|
|
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
|
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
|
|
|
const messages = defineMessages({
|
|
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
|
|
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
|
|
retry: { id: 'chat.retry', defaultMessage: 'Retry?' },
|
|
blocked: { id: 'chat_message_list.blocked', defaultMessage: 'You blocked this user' },
|
|
unblock: { id: 'chat_composer.unblock', defaultMessage: 'Unblock' },
|
|
unblockMessage: { id: 'chat_settings.unblock.message', defaultMessage: 'Unblocking will allow this profile to direct message you and view your content.' },
|
|
unblockHeading: { id: 'chat_settings.unblock.heading', defaultMessage: 'Unblock @{acct}' },
|
|
unblockConfirm: { id: 'chat_settings.unblock.confirm', defaultMessage: 'Unblock' },
|
|
});
|
|
|
|
const initialSuggestionState = {
|
|
list: [],
|
|
tokenStart: 0,
|
|
token: '',
|
|
};
|
|
|
|
interface Suggestion {
|
|
list: { native: string, colons: string }[],
|
|
tokenStart: number,
|
|
token: string,
|
|
}
|
|
|
|
interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onKeyDown' | 'onChange' | 'onPaste' | 'disabled'> {
|
|
value: string
|
|
onSubmit: () => void
|
|
errorMessage: string | undefined
|
|
onSelectFile: (files: FileList, intl: IntlShape) => void
|
|
resetFileKey: number | null
|
|
hasAttachment?: boolean
|
|
}
|
|
|
|
/** Textarea input for chats. */
|
|
const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>(({
|
|
onKeyDown,
|
|
onChange,
|
|
value,
|
|
onSubmit,
|
|
errorMessage = false,
|
|
disabled = false,
|
|
onSelectFile,
|
|
resetFileKey,
|
|
onPaste,
|
|
hasAttachment,
|
|
}, ref) => {
|
|
const dispatch = useAppDispatch();
|
|
const intl = useIntl();
|
|
|
|
const { chat } = useChatContext();
|
|
|
|
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
|
|
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
|
|
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
|
|
|
|
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
|
const isSuggestionsAvailable = suggestions.list.length > 0;
|
|
|
|
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
|
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
|
|
|
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
|
|
|
|
const renderSuggestionValue = (emoji: any) => {
|
|
return `${(value).slice(0, suggestions.tokenStart)}${emoji.native} ${(value as string).slice(suggestions.tokenStart + suggestions.token.length)}`;
|
|
};
|
|
|
|
const onSelectComboboxOption = (selection: string) => {
|
|
const event = { target: { value: selection } } as React.ChangeEvent<HTMLTextAreaElement>;
|
|
|
|
if (onChange) {
|
|
onChange(event);
|
|
setSuggestions(initialSuggestionState);
|
|
}
|
|
};
|
|
|
|
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const [tokenStart, token] = textAtCursorMatchesToken(
|
|
event.target.value,
|
|
event.target.selectionStart,
|
|
[':'],
|
|
);
|
|
|
|
if (token && tokenStart) {
|
|
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
|
|
setSuggestions({
|
|
list: results,
|
|
token,
|
|
tokenStart: tokenStart - 1,
|
|
});
|
|
} else {
|
|
setSuggestions(initialSuggestionState);
|
|
}
|
|
|
|
if (onChange) {
|
|
onChange(event);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey && isSuggestionsAvailable) {
|
|
return;
|
|
}
|
|
|
|
if (onKeyDown) {
|
|
onKeyDown(event);
|
|
}
|
|
};
|
|
|
|
const handleUnblockUser = () => {
|
|
dispatch(openModal('CONFIRM', {
|
|
heading: intl.formatMessage(messages.unblockHeading, { acct: chat?.account.acct }),
|
|
message: intl.formatMessage(messages.unblockMessage),
|
|
confirm: intl.formatMessage(messages.unblockConfirm),
|
|
confirmationTheme: 'primary',
|
|
onConfirm: () => dispatch(unblockAccount(chat?.account.id as string)),
|
|
}));
|
|
};
|
|
|
|
if (isBlocking) {
|
|
return (
|
|
<div className='mt-auto p-6 shadow-3xl dark:border-t-2 dark:border-solid dark:border-gray-800'>
|
|
<Stack space={3} alignItems='center'>
|
|
<Text align='center' theme='muted'>
|
|
{intl.formatMessage(messages.blocked)}
|
|
</Text>
|
|
|
|
<Button theme='secondary' onClick={handleUnblockUser}>
|
|
{intl.formatMessage(messages.unblock)}
|
|
</Button>
|
|
</Stack>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isBlocked) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className='mt-auto px-4 shadow-3xl'>
|
|
<HStack alignItems='stretch' justifyContent='between' space={4}>
|
|
<Stack justifyContent='end' alignItems='center' className='w-10 mb-1.5'>
|
|
<UploadButton onSelectFile={onSelectFile} resetFileKey={resetFileKey} />
|
|
</Stack>
|
|
|
|
<Stack grow>
|
|
<Combobox
|
|
aria-labelledby='demo'
|
|
onSelect={onSelectComboboxOption}
|
|
>
|
|
<ComboboxInput
|
|
as={Textarea}
|
|
autoFocus
|
|
ref={ref}
|
|
placeholder={intl.formatMessage(messages.placeholder)}
|
|
onKeyDown={handleKeyDown}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onPaste={onPaste}
|
|
isResizeable={false}
|
|
autoGrow
|
|
maxRows={5}
|
|
disabled={disabled}
|
|
autoComplete='off'
|
|
/>
|
|
{isSuggestionsAvailable ? (
|
|
<ComboboxPopover>
|
|
<ComboboxList>
|
|
{suggestions.list.map((emojiSuggestion) => (
|
|
<ComboboxOption
|
|
key={emojiSuggestion.colons}
|
|
value={renderSuggestionValue(emojiSuggestion)}
|
|
>
|
|
<span>{emojiSuggestion.native}</span>
|
|
<span className='ml-1'>
|
|
{emojiSuggestion.colons}
|
|
</span>
|
|
</ComboboxOption>
|
|
))}
|
|
</ComboboxList>
|
|
</ComboboxPopover>
|
|
) : null}
|
|
</Combobox>
|
|
</Stack>
|
|
|
|
<Stack space={2} justifyContent='end' alignItems='center' className='w-10 mb-1.5'>
|
|
{isOverCharacterLimit ? (
|
|
<Text size='sm' theme='danger'>{overLimitText}</Text>
|
|
) : null}
|
|
|
|
<IconButton
|
|
src={require('@tabler/icons/send.svg')}
|
|
iconClassName='w-5 h-5'
|
|
className='text-primary-500'
|
|
disabled={isSubmitDisabled}
|
|
onClick={onSubmit}
|
|
/>
|
|
</Stack>
|
|
</HStack>
|
|
|
|
<HStack alignItems='center' className='h-5' space={1}>
|
|
{errorMessage && (
|
|
<>
|
|
<Text theme='danger' size='xs'>
|
|
{errorMessage}
|
|
</Text>
|
|
|
|
<button onClick={onSubmit} className='flex hover:underline'>
|
|
<Text theme='primary' size='xs' tag='span'>
|
|
{intl.formatMessage(messages.retry)}
|
|
</Text>
|
|
</button>
|
|
</>
|
|
)}
|
|
</HStack>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default ChatComposer; |