+22
-2
@@ -1,6 +1,6 @@
|
||||
import { siteId, url } from './settings';
|
||||
import { BASE_URL } from './constants';
|
||||
import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL } from './types';
|
||||
import { BASE_URL, API_BASE } from './constants';
|
||||
import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL, Image } from './types';
|
||||
import fetcher from './fetcher';
|
||||
|
||||
/* common */
|
||||
@@ -234,6 +234,25 @@ export const enableComments = (): Promise<void> =>
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const uploadImage = (image: File): Promise<Image> => {
|
||||
const data = new FormData();
|
||||
data.append('file', image);
|
||||
|
||||
return fetcher
|
||||
.post<{ id: string }>({
|
||||
url: `/picture`,
|
||||
withCredentials: true,
|
||||
contentType: 'multipart/form-data',
|
||||
body: data,
|
||||
})
|
||||
.then(resp => ({
|
||||
name: image.name,
|
||||
size: image.size,
|
||||
type: image.type,
|
||||
url: BASE_URL + API_BASE + '/picture/' + resp.id,
|
||||
}));
|
||||
};
|
||||
|
||||
export default {
|
||||
logIn,
|
||||
logOut,
|
||||
@@ -260,4 +279,5 @@ export default {
|
||||
getBlocked,
|
||||
disableComments,
|
||||
enableComments,
|
||||
uploadImage,
|
||||
};
|
||||
|
||||
+31
-12
@@ -6,29 +6,46 @@ import { getCookie } from './cookies';
|
||||
export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
|
||||
const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head'];
|
||||
|
||||
type FetcherInit =
|
||||
| string
|
||||
| {
|
||||
url: string;
|
||||
body?: string | object | Blob | ArrayBuffer;
|
||||
overriddenApiBase?: string;
|
||||
withCredentials?: boolean;
|
||||
};
|
||||
interface FetcherInitBase {
|
||||
url: string;
|
||||
overriddenApiBase?: string;
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
|
||||
interface FetcherInitJSON extends FetcherInitBase {
|
||||
contentType?: 'application/json';
|
||||
body?: string | object | Blob | ArrayBuffer;
|
||||
}
|
||||
|
||||
interface FetcherInitMultipart extends FetcherInitBase {
|
||||
contentType: 'multipart/form-data';
|
||||
body: FormData;
|
||||
}
|
||||
|
||||
type FetcherInit = string | FetcherInitJSON | FetcherInitMultipart;
|
||||
|
||||
type FetcherObject = { [K in FetcherMethod]: <T = unknown>(data: FetcherInit) => Promise<T> };
|
||||
|
||||
const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
|
||||
acc[method] = <T = unknown>(data: FetcherInit): Promise<T> => {
|
||||
const { url, body = undefined, withCredentials = false, overriddenApiBase = API_BASE } =
|
||||
typeof data === 'string' ? { url: data } : data;
|
||||
const {
|
||||
url,
|
||||
body = undefined,
|
||||
withCredentials = false,
|
||||
overriddenApiBase = API_BASE,
|
||||
contentType = 'application/json',
|
||||
} = typeof data === 'string' ? { url: data } : data;
|
||||
const basename = `${BASE_URL}${overriddenApiBase}`;
|
||||
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCookie('XSRF-TOKEN') || '',
|
||||
});
|
||||
|
||||
if (contentType !== 'multipart/form-data') {
|
||||
headers.append('Content-Type', contentType);
|
||||
}
|
||||
|
||||
let rurl = `${basename}${url}`;
|
||||
|
||||
const parameters: RequestInit = {
|
||||
@@ -39,7 +56,9 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
|
||||
};
|
||||
|
||||
if (body) {
|
||||
if (typeof body === 'object') {
|
||||
if (contentType === 'multipart/form-data') {
|
||||
parameters.body = body as FormData;
|
||||
} else if (typeof body === 'object' && !(body instanceof Blob) && !(body instanceof ArrayBuffer)) {
|
||||
parameters.body = JSON.stringify(body);
|
||||
} else {
|
||||
parameters.body = body;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const StaticStore: StaticStoreType = {
|
||||
low_score: 0,
|
||||
positive_score: false,
|
||||
readonly_age: 0,
|
||||
max_image_size: 0,
|
||||
},
|
||||
query: querySettings as QuerySettingsType,
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface Config {
|
||||
critical_score: number;
|
||||
positive_score: boolean;
|
||||
readonly_age: number;
|
||||
max_image_size: number;
|
||||
}
|
||||
|
||||
export interface RemarkConfig {
|
||||
@@ -138,3 +139,23 @@ export enum CommentMode {
|
||||
Reply,
|
||||
Edit,
|
||||
}
|
||||
|
||||
/**
|
||||
* Used as api.uploadImage response type
|
||||
*/
|
||||
export interface Image {
|
||||
name: string;
|
||||
size: number;
|
||||
/** mime type of an image */
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** error struct returned in case of api call error */
|
||||
export interface ApiError {
|
||||
code: number;
|
||||
/** simple explanation */
|
||||
details: string;
|
||||
/** in-depth explanation */
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS }
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import debounce from '@app/utils/debounce';
|
||||
import copy from '@app/common/copy';
|
||||
import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode } from '@app/common/types';
|
||||
import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode, Image } from '@app/common/types';
|
||||
import { extractErrorMessageFromResponse, FetcherResponse } from '@app/utils/errorUtils';
|
||||
|
||||
import { Input } from '@app/components/input';
|
||||
@@ -54,6 +54,7 @@ export interface Props {
|
||||
blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise<void>;
|
||||
unblockUser?(id: User['id']): Promise<void>;
|
||||
setVerifyStatus?(id: User['id'], value: boolean): Promise<void>;
|
||||
uploadImage?(image: File): Promise<Image>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
@@ -675,6 +676,7 @@ export class Comment extends Component<Props, State> {
|
||||
onCancel={this.toggleReplying}
|
||||
getPreview={this.props.getPreview!}
|
||||
autofocus={true}
|
||||
uploadImage={this.props.uploadImage!}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -689,6 +691,7 @@ export class Comment extends Component<Props, State> {
|
||||
getPreview={this.props.getPreview!}
|
||||
errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined}
|
||||
autofocus={true}
|
||||
uploadImage={this.props.uploadImage!}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { blockUser, unblockUser, setVirifiedStatus } from '@app/store/user/actio
|
||||
|
||||
import { Comment, Props } from './comment';
|
||||
import { getCommentMode } from '@app/store/comments/getters';
|
||||
import { uploadImage } from '@app/common/api';
|
||||
|
||||
const mapProps = (state: StoreState, cprops: { data: CommentType }) => {
|
||||
const props: Pick<
|
||||
@@ -51,6 +52,7 @@ const mapDispatchToProps = (dispatch: StoreDispatch) => {
|
||||
| 'blockUser'
|
||||
| 'unblockUser'
|
||||
| 'setVerifyStatus'
|
||||
| 'uploadImage'
|
||||
> = {
|
||||
addComment: (text: string, title: string, pid?: CommentType['id']) => dispatch(addComment(text, title, pid)),
|
||||
updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)),
|
||||
@@ -63,6 +65,8 @@ const mapDispatchToProps = (dispatch: StoreDispatch) => {
|
||||
blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)),
|
||||
unblockUser: (id: User['id']) => dispatch(unblockUser(id)),
|
||||
setVerifyStatus: (id: User['id'], value: boolean) => dispatch(setVirifiedStatus(id, value)),
|
||||
// should i made it as store action?
|
||||
uploadImage: (image: File) => uploadImage(image),
|
||||
};
|
||||
|
||||
return props;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
.input__preview-wrapper {
|
||||
background: #eee;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.input__toolbar-item {
|
||||
|
||||
@@ -7,7 +7,7 @@ import './styles';
|
||||
import { h, Component, RenderableProps } from 'preact';
|
||||
import b, { Mix } from 'bem-react-helper';
|
||||
|
||||
import { User, Theme } from '@app/common/types';
|
||||
import { User, Theme, Image, ApiError } from '@app/common/types';
|
||||
import { BASE_URL, API_BASE } from '@app/common/constants';
|
||||
import { StaticStore } from '@app/common/static_store';
|
||||
import { siteId, url, pageTitle } from '@app/common/settings';
|
||||
@@ -15,6 +15,8 @@ import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
|
||||
|
||||
import MarkdownToolbar from './markdown-toolbar';
|
||||
import TextareaAutosize from './textarea-autosize';
|
||||
import { sleep } from '@app/utils/sleep';
|
||||
import { replaceSelection } from '@app/utils/replaceSelection';
|
||||
|
||||
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
|
||||
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
|
||||
@@ -36,15 +38,22 @@ interface Props {
|
||||
getPreview(text: string): Promise<string>;
|
||||
/** action on cancel. optional as root input has no cancel option */
|
||||
onCancel?: () => void;
|
||||
uploadImage: (image: File) => Promise<Image>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
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;
|
||||
maxLength: number;
|
||||
/** main input value */
|
||||
text: string;
|
||||
/** override main button text */
|
||||
buttonText: null | string;
|
||||
}
|
||||
|
||||
const Labels = {
|
||||
@@ -53,7 +62,10 @@ const Labels = {
|
||||
reply: 'Reply',
|
||||
};
|
||||
|
||||
const ImageMimeRegex = /image\//i;
|
||||
|
||||
export class Input extends Component<Props, State> {
|
||||
/** reference to textarea element */
|
||||
textAreaRef?: TextareaAutosize;
|
||||
textareaId: string;
|
||||
constructor(props: Props) {
|
||||
@@ -64,15 +76,22 @@ export class Input extends Component<Props, State> {
|
||||
preview: null,
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
errorLock: false,
|
||||
isDisabled: false,
|
||||
maxLength: StaticStore.config.max_comment_size,
|
||||
text: props.value || '',
|
||||
buttonText: null,
|
||||
};
|
||||
|
||||
this.send = this.send.bind(this);
|
||||
this.getPreview = this.getPreview.bind(this);
|
||||
this.onInput = this.onInput.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);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
@@ -101,10 +120,17 @@ export class Input extends Component<Props, State> {
|
||||
}
|
||||
|
||||
onInput(e: Event) {
|
||||
if (this.state.errorLock) {
|
||||
this.setState({
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
preview: null,
|
||||
isErrorShown: false,
|
||||
errorMessage: null,
|
||||
preview: null,
|
||||
text: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
}
|
||||
@@ -152,10 +178,165 @@ export class Input extends Component<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
render(props: RenderableProps<Props>, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text }: State) {
|
||||
/** 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 (StaticStore.config.max_image_size === 0) return;
|
||||
if (!this.textAreaRef) 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** wrapper with error handling for props.uploadImage */
|
||||
uploadImage(file: File): Promise<Image | Error> {
|
||||
return this.props
|
||||
.uploadImage(file)
|
||||
.catch(
|
||||
(e: ApiError | string) =>
|
||||
new Error(
|
||||
typeof e === 'string'
|
||||
? `${file.name} upload failed with "${e}"`
|
||||
: `${file.name} upload failed with "${e.error}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** performs upload process */
|
||||
async uploadImages(files: File[]) {
|
||||
if (!this.textAreaRef) 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;
|
||||
|
||||
const isSelectionSupported = this.textAreaRef.isSelectionSupported();
|
||||
|
||||
this.setState({
|
||||
errorLock: true,
|
||||
errorMessage: null,
|
||||
isErrorShown: false,
|
||||
isDisabled: true,
|
||||
buttonText: 'Uploading...',
|
||||
});
|
||||
|
||||
// fallback for ie < 9
|
||||
if (!isSelectionSupported) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const isFirst = i === 0;
|
||||
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: this.state.text + markdownString,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
return;
|
||||
}
|
||||
|
||||
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}![uploading ${file.name}...]()`;
|
||||
const uploadPlaceholderLength = uploadPlaceholder.length;
|
||||
const selection = this.textAreaRef.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.textAreaRef!.setSelection(selection);
|
||||
};
|
||||
|
||||
if (file.size > StaticStore.config.max_image_size) {
|
||||
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, selection, uploadPlaceholder),
|
||||
});
|
||||
|
||||
!isFirst && (await sleep(uploadDelay));
|
||||
|
||||
const result = await this.uploadImage(file);
|
||||
|
||||
if (result instanceof Error) {
|
||||
this.appendError(result.message);
|
||||
await restoreSelection();
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdownString = `${placeholderStart}`;
|
||||
this.setState({
|
||||
text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString),
|
||||
});
|
||||
/** sleeping awhile so textarea catch state change and its selection */
|
||||
await sleep(100);
|
||||
const selectionPointer = selection[0] + markdownString.length;
|
||||
this.textAreaRef.setSelection([selectionPointer, selectionPointer]);
|
||||
}
|
||||
|
||||
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
|
||||
}
|
||||
|
||||
render(
|
||||
props: RenderableProps<Props>,
|
||||
{ isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State
|
||||
) {
|
||||
const charactersLeft = maxLength - text.length;
|
||||
errorMessage = props.errorMessage || errorMessage;
|
||||
const label = Labels[props.mode || 'main'];
|
||||
const label = buttonText || Labels[props.mode || 'main'];
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -168,6 +349,8 @@ export class Input extends Component<Props, State> {
|
||||
})}
|
||||
onSubmit={this.send}
|
||||
aria-label="New comment"
|
||||
onDragOver={this.onDragOver}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
<div className="input__control-panel">
|
||||
<MarkdownToolbar textareaId={this.textareaId} />
|
||||
@@ -189,11 +372,12 @@ export class Input extends Component<Props, State> {
|
||||
{charactersLeft < 100 && <span className="input__counter">{charactersLeft}</span>}
|
||||
</div>
|
||||
|
||||
{(isErrorShown || !!errorMessage) && (
|
||||
<p className="input__error" role="alert">
|
||||
{errorMessage || 'Something went wrong. Please try again a bit later.'}
|
||||
</p>
|
||||
)}
|
||||
{(isErrorShown || !!errorMessage) &&
|
||||
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
|
||||
<p className="input__error" role="alert" key={e}>
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="input__actions">
|
||||
<button
|
||||
|
||||
@@ -13,11 +13,13 @@ export default class TextareaAutosize extends Component<Props> {
|
||||
|
||||
this.onRef = this.onRef.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.autoResize();
|
||||
|
||||
if (this.props.autofocus) this.focus();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.autoResize();
|
||||
@@ -33,9 +35,30 @@ export default class TextareaAutosize extends Component<Props> {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/** returns whether selectionStart api supported */
|
||||
isSelectionSupported(): boolean {
|
||||
if (!this.textareaRef) throw new Error('No textarea element reference exists');
|
||||
return 'selectionStart' in this.textareaRef;
|
||||
}
|
||||
|
||||
/** returns selection range of a textarea */
|
||||
getSelection(): [number, number] {
|
||||
if (!this.textareaRef) throw new Error('No textarea element reference exists');
|
||||
|
||||
return [this.textareaRef.selectionStart, this.textareaRef.selectionEnd];
|
||||
}
|
||||
|
||||
/** sets selection range of a textarea */
|
||||
setSelection(selection: [number, number]) {
|
||||
if (!this.textareaRef) throw new Error('No textarea element reference exists');
|
||||
this.textareaRef.selectionStart = selection[0];
|
||||
this.textareaRef.selectionEnd = selection[1];
|
||||
}
|
||||
|
||||
onRef(node: HTMLTextAreaElement) {
|
||||
this.textareaRef = node;
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
if (this.textareaRef) {
|
||||
this.textareaRef.style.height = '';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Theme,
|
||||
AuthProvider,
|
||||
BlockTTL,
|
||||
Image,
|
||||
} from '@app/common/types';
|
||||
import {
|
||||
NODE_ID,
|
||||
@@ -47,6 +48,7 @@ import { ConnectedComment as Comment } from '@app/components/comment/connected-c
|
||||
import { Input } from '@app/components/input';
|
||||
import Preloader from '@app/components/preloader';
|
||||
import { Thread } from '@app/components/thread';
|
||||
import { uploadImage } from '@app/common/api';
|
||||
|
||||
interface Props {
|
||||
user: User | null;
|
||||
@@ -73,6 +75,7 @@ interface Props {
|
||||
unblockUser(id: User['id']): Promise<void>;
|
||||
addComment(text: string, title: string, pid?: CommentType['id']): Promise<void>;
|
||||
updateComment(id: string, text: string): Promise<void>;
|
||||
uploadImage(image: File): Promise<Image>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -232,6 +235,7 @@ export class Root extends Component<Props, State> {
|
||||
userId={this.props.user!.id}
|
||||
onSubmit={(text, title) => this.props.addComment(text, title)}
|
||||
getPreview={this.props.getPreview}
|
||||
uploadImage={this.props.uploadImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -311,6 +315,7 @@ const mapDispatchToProps = (dispatch: StoreDispatch) => {
|
||||
addComment: (text: string, pageTitle: string, pid?: CommentType['id']) =>
|
||||
dispatch(addComment(text, pageTitle, pid)),
|
||||
updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)),
|
||||
uploadImage: (image: File) => uploadImage(image),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function replaceSelection(text: string, selection: [number, number], replacement: string): string {
|
||||
return text.substr(0, selection[0]) + replacement + text.substr(selection[1]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number = 1000): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user