import { h, Component, createRef, Fragment } from 'preact'; import { FormattedMessage, IntlShape, defineMessages } from 'react-intl'; import b, { Mix } from 'bem-react-helper'; import { User, Theme, Image, ApiError } from 'common/types'; import { StaticStore } from 'common/static-store'; import * as settings from 'common/settings'; import { extractErrorMessageFromResponse } from 'utils/errorUtils'; import { isUserAnonymous } from 'utils/isUserAnonymous'; import { sleep } from 'utils/sleep'; import { replaceSelection } from 'utils/replaceSelection'; import { Button } from 'components/button'; import { TextareaAutosize } from 'components/textarea-autosize'; import { Auth } from 'components/auth'; import { getJsonItem, updateJsonItem } from 'common/local-storage'; import { LS_SAVED_COMMENT_VALUE } from 'common/constants'; import { SubscribeByEmail } from './__subscribe-by-email'; import { SubscribeByRSS } from './__subscribe-by-rss'; import { MarkdownToolbar } from './markdown-toolbar'; import { TextExpander } from './text-expander'; export type CommentFormProps = { id: string; user: User | null; errorMessage?: string; value?: string; mix?: Mix; mode?: 'main' | 'edit' | 'reply'; theme: Theme; autofocus?: boolean; onSubmit(text: string, pageTitle: string): Promise; getPreview(text: string): Promise; /** action on cancel. optional as root input has no cancel option */ onCancel?(): void; uploadImage?(image: File): Promise; intl: IntlShape; }; export type CommentFormState = { preview: string | null; isErrorShown: boolean; /** error message, if contains newlines, it will be split to multiple errors */ errorMessage: string | null; /** prevents error hiding on input event */ errorLock: boolean; isDisabled: boolean; /** main input value */ text: string; /** override main button text */ buttonText: null | string; }; const ImageMimeRegex = /image\//i; export const messages = defineMessages({ placeholder: { id: 'commentForm.input-placeholder', defaultMessage: 'Your comment here', }, uploadFileFail: { id: 'commentForm.upload-file-fail', defaultMessage: '{fileName} upload failed with "{errorMessage}"', }, uploading: { id: 'commentForm.uploading', defaultMessage: 'Uploading...', }, uploadingFile: { id: 'commentForm.uploading-file', defaultMessage: 'uploading {fileName}...', }, exceededSize: { id: 'commentForm.exceeded-size', defaultMessage: '{fileName} exceeds size limit of {maxImageSize}', }, newComment: { id: 'commentForm.new-comment', defaultMessage: 'New comment', }, unexpectedError: { id: 'commentForm.unexpected-error', defaultMessage: 'Something went wrong. Please try again a bit later.', }, unauthorizedUploadingDisabled: { id: 'commentForm.unauthorized-uploading-disabled', defaultMessage: 'Image uploading is disabled for unauthorized users. You should login before uploading.', }, anonymousUploadingDisabled: { id: 'commentForm.anonymous-uploading-disabled', defaultMessage: 'Image uploading is disabled for anonymous users. Please log in not as anonymous user to be able to attach images.', }, }); export class CommentForm extends Component { /** reference to textarea element */ textareaRef = createRef(); static textareaId = 0; state = { preview: null, isErrorShown: false, errorMessage: null, errorLock: false, isDisabled: false, text: '', buttonText: null, }; constructor(props: CommentFormProps) { super(props); const savedComments = getJsonItem>(LS_SAVED_COMMENT_VALUE); this.state.text = props.value ?? savedComments?.[props.id] ?? ''; CommentForm.textareaId += 1; } componentWillReceiveProps(nextProps: CommentFormProps) { if (nextProps.value !== this.props.value) { this.setState({ text: nextProps.value || '' }); } if (nextProps.user && !this.props.value) { this.setState({ isErrorShown: false, errorMessage: null, }); } } shouldComponentUpdate(nextProps: CommentFormProps, nextState: CommentFormState) { const userId = this.props.user !== null && this.props.user.id; const nextUserId = nextProps.user !== null && nextProps.user.id; return ( nextUserId !== userId || nextProps.mode !== this.props.mode || nextProps.theme !== this.props.theme || nextProps.value !== this.props.value || nextProps.errorMessage !== this.props.errorMessage || nextState !== this.state ); } onKeyDown = (e: KeyboardEvent) => { // send on cmd+enter / ctrl+enter if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { this.send(e); } }; onInput = (e: Event) => { const { value } = e.target as HTMLInputElement; const text = value.substr(0, StaticStore.config.max_comment_size); updateJsonItem(LS_SAVED_COMMENT_VALUE, { [this.props.id]: value }); if (this.state.errorLock) { this.setState({ preview: null, text, }); return; } this.setState({ isErrorShown: false, errorMessage: null, preview: null, text, }); }; onPaste = async (e: ClipboardEvent) => { if (!(e.clipboardData && e.clipboardData.files.length > 0)) { return; } e.preventDefault(); const files = Array.from(e.clipboardData.files); await this.uploadImages(files); }; send = async (e: Event) => { const { text } = this.state; if (e) e.preventDefault(); if (!text || !text.trim()) return; if (text === this.props.value) { this.props.onCancel && this.props.onCancel(); this.setState({ preview: null, text: '' }); } this.setState({ isDisabled: true, isErrorShown: false, text }); try { await this.props.onSubmit(text, settings.pageTitle || document.title); } catch (e) { this.setState({ isDisabled: false, isErrorShown: true, // @ts-ignore errorMessage: extractErrorMessageFromResponse(e, this.props.intl), }); return; } updateJsonItem | null>(LS_SAVED_COMMENT_VALUE, (data) => { if (data === null) { return null; } delete data[this.props.id]; return data; }); this.setState({ isDisabled: false, preview: null, text: '' }); }; getPreview = () => { const text = this.textareaRef.current?.value ?? this.state.text; if (!text || !text.trim()) return; this.setState({ isErrorShown: false, errorMessage: null, text }); this.props .getPreview(text) .then((preview) => this.setState({ preview })) .catch(() => { this.setState({ isErrorShown: true, errorMessage: null }); }); }; /** appends error to input's error block */ appendError = (...errors: string[]) => { if (!this.state.errorMessage) { this.setState({ errorMessage: errors.join('\n'), isErrorShown: true, }); return; } this.setState({ errorMessage: `${this.state.errorMessage}\n${errors.join('\n')}`, isErrorShown: true, }); }; onDragOver = (e: DragEvent) => { if (!this.props.user) e.preventDefault(); if (!this.props.uploadImage) return; if (StaticStore.config.max_image_size === 0) return; if (!this.textareaRef.current) return; if (!e.dataTransfer) return; const items = Array.from(e.dataTransfer.items); if (Array.from(items).filter((i) => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }; onDrop = (e: DragEvent) => { const isAnonymous = this.props.user && isUserAnonymous(this.props.user); if (!this.props.user || isAnonymous) { const message = isAnonymous ? messages.anonymousUploadingDisabled : messages.unauthorizedUploadingDisabled; this.setState({ isErrorShown: true, errorMessage: this.props.intl.formatMessage(message), }); e.preventDefault(); return; } if (!this.props.uploadImage) return; if (StaticStore.config.max_image_size === 0) return; if (!e.dataTransfer) return; const data = Array.from(e.dataTransfer.files).filter((f) => ImageMimeRegex.test(f.type)); if (data.length === 0) return; e.preventDefault(); this.uploadImages(data); }; /** returns selection range of a textarea */ getSelection(): [number, number] { const textarea = this.textareaRef.current; if (textarea) { return [textarea.selectionStart, textarea.selectionEnd]; } throw new Error('No textarea element reference exists'); } /** sets selection range of a textarea */ setSelection(selection: [number, number]) { const textarea = this.textareaRef.current; if (textarea) { textarea.selectionStart = selection[0]; textarea.selectionEnd = selection[1]; return; } throw new Error('No textarea element reference exists'); } /** wrapper with error handling for props.uploadImage */ uploadImage = (file: File): Promise => { const intl = this.props.intl; return this.props.uploadImage!(file).catch((e: ApiError | string) => { return new Error( intl.formatMessage(messages.uploadFileFail, { fileName: file.name, errorMessage: extractErrorMessageFromResponse(e, this.props.intl), }) ); }); }; /** performs upload process */ uploadImages = async (files: File[]) => { const intl = this.props.intl; if (!this.props.uploadImage) return; if (!this.textareaRef.current) return; /** Human readable image size limit, i.e 5MB */ const maxImageSizeString = `${(StaticStore.config.max_image_size / 1024 / 1024).toFixed(2)}MB`; /** upload delay to avoid server rate limiter */ const uploadDelay = 5000; this.setState({ errorLock: true, errorMessage: null, isErrorShown: false, isDisabled: true, buttonText: intl.formatMessage(messages.uploading), }); for (let i = 0; i < files.length; i++) { const file = files[i]; const isFirst = i === 0; const placeholderStart = this.state.text.length === 0 ? '' : '\n'; const uploadPlaceholder = `${placeholderStart}![${intl.formatMessage(messages.uploadingFile, { fileName: file.name, })}]()`; const uploadPlaceholderLength = uploadPlaceholder.length; const selection = this.getSelection(); /** saved selection in case of error */ const originalText = this.state.text; const restoreSelection = async () => { this.setState({ text: originalText, }); /** sleeping awhile so textarea catch state change and its selection */ await sleep(100); this.setSelection(selection); }; if (file.size > StaticStore.config.max_image_size) { this.appendError( intl.formatMessage(messages.exceededSize, { fileName: file.name, maxImageSize: maxImageSizeString, }) ); continue; } this.setState({ text: replaceSelection(this.state.text, selection, uploadPlaceholder) }, () => { updateJsonItem(LS_SAVED_COMMENT_VALUE, { [this.props.id]: this.state.text }); }); !isFirst && (await sleep(uploadDelay)); const result = await this.uploadImage(file); if (result instanceof Error) { this.appendError(result.message); await restoreSelection(); continue; } const markdownString = `${placeholderStart}![${result.name}](${result.url})`; this.setState( { text: replaceSelection( this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString ), }, () => { updateJsonItem(LS_SAVED_COMMENT_VALUE, { [this.props.id]: this.state.text }); } ); /** sleeping awhile so textarea catch state change and its selection */ await sleep(100); const selectionPointer = selection[0] + markdownString.length; this.setSelection([selectionPointer, selectionPointer]); } this.setState({ errorLock: false, isDisabled: false, buttonText: null }); }; renderMarkdownTip = () => (
( {title} ), }} />
); renderSubscribeButtons = () => { const isEmailNotifications = StaticStore.config.email_notifications; const isEmailSubscription = isEmailNotifications && settings.isEmailSubscription; const { isRssSubscription } = settings; if (!isRssSubscription && !isEmailSubscription) { return null; } return ( <> {' '} {isRssSubscription && } {isRssSubscription && isEmailSubscription && ( <> {' '} {' '} )} {isEmailSubscription && } ); }; render() { const { theme, mode, mix, uploadImage, autofocus, user, intl } = this.props; const { isDisabled, isErrorShown, preview, text, buttonText } = this.state; const charactersLeft = StaticStore.config.max_comment_size - text.length; const errorMessage = this.props.errorMessage || this.state.errorMessage; const Labels = { main: , edit: , reply: , }; const textareaId = `textarea_${CommentForm.textareaId}`; const label = buttonText || Labels[mode || 'main']; const placeholderMessage = intl.formatMessage(messages.placeholder); const isSimpleView = StaticStore.config.simple_view; return (
{!isSimpleView && (
)}
{charactersLeft < 100 && {charactersLeft}}
{(isErrorShown || !!errorMessage) && (errorMessage || intl.formatMessage(messages.unexpectedError)).split('\n').map((e) => (

{e}

))}
{user ? ( <>
{!isSimpleView && ( )}
{mode === 'main' && (
{this.renderMarkdownTip()} {this.renderSubscribeButtons()}
)} ) : ( <> {this.renderMarkdownTip()} )}
{ // TODO: it can be more elegant; // for example it can render full comment component here (or above textarea on mobile) !!preview && (
) } ); } }