Files
remark42/frontend/app/components/comment-form/comment-form.tsx
T
2021-05-06 15:55:46 -05:00

564 lines
18 KiB
TypeScript

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 { pageTitle } 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';
let textareaId = 0;
export type CommentFormProps = {
id: string;
user: User | null;
errorMessage?: string;
value?: string;
mix?: Mix;
mode?: 'main' | 'edit' | 'reply';
theme: Theme;
simpleView?: boolean;
autofocus?: boolean;
onSubmit(text: string, pageTitle: string): Promise<void>;
getPreview(text: string): Promise<string>;
/** action on cancel. optional as root input has no cancel option */
onCancel?: () => void;
uploadImage?: (image: File) => Promise<Image>;
intl: IntlShape;
};
export type CommentFormState = {
preview: string | null;
isErrorShown: boolean;
/** error message, if contains newlines, it will be splitted 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<CommentFormProps, CommentFormState> {
/** reference to textarea element */
textareaRef = createRef<HTMLTextAreaElement>();
textareaId: string;
constructor(props: CommentFormProps) {
super(props);
textareaId = textareaId + 1;
this.textareaId = `textarea_${textareaId}`;
const savedComments = getJsonItem<Record<string, string>>(LS_SAVED_COMMENT_VALUE);
let text = savedComments?.[props.id] ?? '';
if (props.value) {
text = props.value;
}
this.state = {
preview: null,
isErrorShown: false,
errorMessage: null,
errorLock: false,
isDisabled: false,
text,
buttonText: null,
};
this.getPreview = this.getPreview.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.onDrop = this.onDrop.bind(this);
this.appendError = this.appendError.bind(this);
this.uploadImage = this.uploadImage.bind(this);
this.uploadImages = this.uploadImages.bind(this);
this.onPaste = this.onPaste.bind(this);
}
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,
});
};
async onPaste(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, pageTitle || document.title);
} catch (e) {
this.setState({
isDisabled: false,
isErrorShown: true,
errorMessage: extractErrorMessageFromResponse(e, this.props.intl),
});
return;
}
updateJsonItem<Record<string, string> | 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<Image | Error> {
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 */
async uploadImages(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 = () => (
<div className="comment-form__markdown">
<FormattedMessage
id="commentForm.notice-about-styling"
defaultMessage="Styling with <a>Markdown</a> is supported"
values={{
a: (title: string) => (
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
{title}
</a>
),
}}
/>
</div>
);
render() {
const { theme, mode, simpleView, 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: <FormattedMessage id="commentForm.send" defaultMessage="Send" />,
edit: <FormattedMessage id="commentForm.save" defaultMessage="Save" />,
reply: <FormattedMessage id="commentForm.reply" defaultMessage="Reply" />,
};
const label = buttonText || Labels[mode || 'main'];
const placeholderMessage = intl.formatMessage(messages.placeholder);
return (
<form
className={b('comment-form', {
mods: {
theme,
type: mode || 'reply',
simple: simpleView,
},
mix,
})}
onSubmit={this.send}
aria-label={intl.formatMessage(messages.newComment)}
onDragOver={this.onDragOver}
onDrop={this.onDrop}
>
{!simpleView && (
<div className="comment-form__control-panel">
<MarkdownToolbar
intl={intl}
allowUpload={Boolean(uploadImage)}
uploadImages={this.uploadImages}
textareaId={this.textareaId}
/>
</div>
)}
<div className="comment-form__field-wrapper">
<TextExpander>
<TextareaAutosize
id={this.textareaId}
ref={this.textareaRef}
onPaste={this.onPaste}
className="comment-form__field"
placeholder={placeholderMessage}
value={text}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!autofocus}
spellcheck={true}
/>
</TextExpander>
{charactersLeft < 100 && <span className="comment-form__counter">{charactersLeft}</span>}
</div>
{(isErrorShown || !!errorMessage) &&
(errorMessage || intl.formatMessage(messages.unexpectedError)).split('\n').map((e) => (
<p className="comment-form__error" role="alert" key={e}>
{e}
</p>
))}
<div className="comment-form__actions">
{user ? (
<>
<div>
{!simpleView && (
<Button
kind="secondary"
theme={theme}
size="large"
mix="comment-form__button"
disabled={isDisabled}
onClick={this.getPreview}
>
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
{label}
</Button>
</div>
{!simpleView && mode === 'main' && (
<div className="comment-form__rss">
{this.renderMarkdownTip()}
<FormattedMessage id="commentForm.subscribe-by" defaultMessage="Subscribe by" />{' '}
<SubscribeByRSS userId={user !== null ? user.id : null} />
{StaticStore.config.email_notifications && StaticStore.query.show_email_subscription && (
<>
{' '}
<FormattedMessage id="commentForm.subscribe-or" defaultMessage="or" /> <SubscribeByEmail />
</>
)}
</div>
)}
</>
) : (
<>
<Auth />
{this.renderMarkdownTip()}
</>
)}
</div>
{
// TODO: it can be more elegant;
// for example it can render full comment component here (or above textarea on mobile)
!!preview && (
<div className="comment-form__preview-wrapper">
<div
className={b('comment-form__preview', {
mix: b('raw-content', {}, { theme }),
})}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: preview }}
/>
</div>
)
}
</form>
);
}
}