WIP image upload, this needs cleanup
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
3c5025c7f3
commit
f55a76886f
5 changed files with 210 additions and 31 deletions
|
@ -33,6 +33,11 @@ const { CancelToken, isCancel } = axios;
|
||||||
|
|
||||||
let cancelFetchComposeSuggestions: Canceler;
|
let cancelFetchComposeSuggestions: Canceler;
|
||||||
|
|
||||||
|
const FILES_UPLOAD_REQUEST = 'FILES_UPLOAD_REQUEST' as const;
|
||||||
|
const FILES_UPLOAD_SUCCESS = 'FILES_UPLOAD_SUCCESS' as const;
|
||||||
|
const FILES_UPLOAD_FAIL = 'FILES_UPLOAD_FAIL' as const;
|
||||||
|
const FILES_UPLOAD_PROGRESS = 'FILES_UPLOAD_PROGRESS' as const;
|
||||||
|
|
||||||
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
|
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
|
||||||
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
|
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
|
||||||
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const;
|
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const;
|
||||||
|
@ -388,6 +393,102 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({
|
||||||
error: error,
|
error: error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadFiles = (files: FileList, intl: IntlShape) =>
|
||||||
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
if (!isLoggedIn(getState)) return;
|
||||||
|
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
|
||||||
|
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
|
||||||
|
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
|
||||||
|
|
||||||
|
const progress = new Array(files.length).fill(0);
|
||||||
|
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||||
|
|
||||||
|
dispatch(uploadFilesRequest());
|
||||||
|
|
||||||
|
return Array.from(files).forEach(async(f, i) => {
|
||||||
|
const isImage = f.type.match(/image.*/);
|
||||||
|
const isVideo = f.type.match(/video.*/);
|
||||||
|
const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(f) : 0;
|
||||||
|
|
||||||
|
if (isImage && maxImageSize && (f.size > maxImageSize)) {
|
||||||
|
const limit = formatBytes(maxImageSize);
|
||||||
|
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
|
||||||
|
toast.error(message);
|
||||||
|
dispatch(uploadFilesFail(true));
|
||||||
|
return;
|
||||||
|
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
|
||||||
|
const limit = formatBytes(maxVideoSize);
|
||||||
|
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
|
||||||
|
toast.error(message);
|
||||||
|
dispatch(uploadFilesFail(true));
|
||||||
|
return;
|
||||||
|
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
|
||||||
|
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
|
||||||
|
toast.error(message);
|
||||||
|
dispatch(uploadFilesFail(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Don't define const in loop
|
||||||
|
resizeImage(f).then(file => {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('file', file);
|
||||||
|
// Account for disparity in size of original image and resized data
|
||||||
|
total += file.size - f.size;
|
||||||
|
|
||||||
|
const onUploadProgress = ({ loaded }: any) => {
|
||||||
|
progress[i] = loaded;
|
||||||
|
dispatch(uploadFilesProgress(progress.reduce((a, v) => a + v, 0), total));
|
||||||
|
};
|
||||||
|
|
||||||
|
return dispatch(uploadMedia(data, onUploadProgress))
|
||||||
|
.then(({ status, data }) => {
|
||||||
|
// If server-side processing of the media attachment has not completed yet,
|
||||||
|
// poll the server until it is, before showing the media attachment as uploaded
|
||||||
|
if (status === 200) {
|
||||||
|
dispatch(uploadFilesSuccess(data, f));
|
||||||
|
} else if (status === 202) {
|
||||||
|
const poll = () =>
|
||||||
|
dispatch(fetchMedia(data.id)).then(({ status, data }) => {
|
||||||
|
if (status === 200) {
|
||||||
|
dispatch(uploadFilesSuccess(data, f));
|
||||||
|
return data;
|
||||||
|
} else if (status === 206) {
|
||||||
|
setTimeout(() => poll(), 1000);
|
||||||
|
}
|
||||||
|
}).catch(error => dispatch(uploadFilesFail(error)));
|
||||||
|
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(error => dispatch(uploadFilesFail(error)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFilesRequest = () => ({
|
||||||
|
type: FILES_UPLOAD_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesProgress = (loaded: number, total: number) => ({
|
||||||
|
type: FILES_UPLOAD_PROGRESS,
|
||||||
|
loaded: loaded,
|
||||||
|
total: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesSuccess = (media: APIEntity, file: File) => ({
|
||||||
|
type: FILES_UPLOAD_SUCCESS,
|
||||||
|
media: media,
|
||||||
|
file,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadFilesFail = (error: AxiosError | true) => ({
|
||||||
|
type: FILES_UPLOAD_FAIL,
|
||||||
|
error: error,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
|
const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return;
|
if (!isLoggedIn(getState)) return;
|
||||||
|
@ -436,7 +537,6 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Don't define const in loop
|
// FIXME: Don't define const in loop
|
||||||
/* eslint-disable no-loop-func */
|
|
||||||
resizeImage(f).then(file => {
|
resizeImage(f).then(file => {
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('file', file);
|
data.append('file', file);
|
||||||
|
@ -469,10 +569,37 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
|
}).catch(error => dispatch(uploadComposeFail(composeId, error)));
|
||||||
/* eslint-enable no-loop-func */
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadComposeRequest = (composeId: string) => ({
|
||||||
|
type: COMPOSE_UPLOAD_REQUEST,
|
||||||
|
id: composeId,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
|
||||||
|
type: COMPOSE_UPLOAD_PROGRESS,
|
||||||
|
id: composeId,
|
||||||
|
loaded: loaded,
|
||||||
|
total: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
|
||||||
|
type: COMPOSE_UPLOAD_SUCCESS,
|
||||||
|
id: composeId,
|
||||||
|
media: media,
|
||||||
|
file,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
|
||||||
|
type: COMPOSE_UPLOAD_FAIL,
|
||||||
|
id: composeId,
|
||||||
|
error: error,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
|
const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (!isLoggedIn(getState)) return;
|
if (!isLoggedIn(getState)) return;
|
||||||
|
@ -507,34 +634,6 @@ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosErro
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadComposeRequest = (composeId: string) => ({
|
|
||||||
type: COMPOSE_UPLOAD_REQUEST,
|
|
||||||
id: composeId,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
|
|
||||||
type: COMPOSE_UPLOAD_PROGRESS,
|
|
||||||
id: composeId,
|
|
||||||
loaded: loaded,
|
|
||||||
total: total,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
|
|
||||||
type: COMPOSE_UPLOAD_SUCCESS,
|
|
||||||
id: composeId,
|
|
||||||
media: media,
|
|
||||||
file,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
|
|
||||||
type: COMPOSE_UPLOAD_FAIL,
|
|
||||||
id: composeId,
|
|
||||||
error: error,
|
|
||||||
skipLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const undoUploadCompose = (composeId: string, media_id: string) => ({
|
const undoUploadCompose = (composeId: string, media_id: string) => ({
|
||||||
type: COMPOSE_UPLOAD_UNDO,
|
type: COMPOSE_UPLOAD_UNDO,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
|
@ -1001,6 +1100,11 @@ export {
|
||||||
submitComposeRequest,
|
submitComposeRequest,
|
||||||
submitComposeSuccess,
|
submitComposeSuccess,
|
||||||
submitComposeFail,
|
submitComposeFail,
|
||||||
|
uploadFiles,
|
||||||
|
uploadFilesRequest,
|
||||||
|
uploadFilesSuccess,
|
||||||
|
uploadFilesProgress,
|
||||||
|
uploadFilesFail,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
changeUploadCompose,
|
changeUploadCompose,
|
||||||
changeUploadComposeRequest,
|
changeUploadComposeRequest,
|
||||||
|
|
Binary file not shown.
|
@ -10,7 +10,7 @@ const messages = defineMessages({
|
||||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlyImages = (types: ImmutableList<string>) => {
|
export const onlyImages = (types: ImmutableList<string>) => {
|
||||||
return Boolean(types && types.every(type => type.startsWith('image/')));
|
return Boolean(types && types.every(type => type.startsWith('image/')));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text';
|
||||||
import { useFeatures, useInstance } from 'soapbox/hooks';
|
import { useFeatures, useInstance } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { EmojiNode } from './emoji-node';
|
import { EmojiNode } from './emoji-node';
|
||||||
|
import { ImageNode } from './image-node';
|
||||||
import { MentionNode } from './mention-node';
|
import { MentionNode } from './mention-node';
|
||||||
|
|
||||||
import type { Klass, LexicalNode } from 'lexical';
|
import type { Klass, LexicalNode } from 'lexical';
|
||||||
|
@ -45,6 +46,7 @@ const useNodes = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode);
|
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_headings'])) nodes.push(HeadingNode);
|
||||||
|
if (instance.pleroma.getIn(['metadata', 'markup', 'allow_inline_images'])) nodes.push(ImageNode);
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,11 +21,73 @@ import {
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { uploadFiles } from 'soapbox/actions/compose';
|
||||||
|
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { onlyImages } from '../../components/upload-button';
|
||||||
|
import { $createImageNode } from '../nodes/image-node';
|
||||||
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
import { setFloatingElemPosition } from '../utils/set-floating-elem-position';
|
||||||
|
|
||||||
import { ToolbarButton } from './floating-text-format-toolbar-plugin';
|
import { ToolbarButton } from './floating-text-format-toolbar-plugin';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
interface IUploadButton {
|
||||||
|
onSelectFile: (src: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadButton: React.FC<IUploadButton> = ({ onSelectFile }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { configuration } = useInstance();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const fileElement = useRef<HTMLInputElement>(null);
|
||||||
|
const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>;
|
||||||
|
|
||||||
|
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
if (e.target.files?.length) {
|
||||||
|
// @ts-ignore
|
||||||
|
dispatch(uploadFiles([e.target.files.item(0)] as any, intl));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileElement.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// if (unavailable) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const src = (
|
||||||
|
onlyImages(attachmentTypes)
|
||||||
|
? require('@tabler/icons/photo.svg')
|
||||||
|
: require('@tabler/icons/paperclip.svg')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label='Upload media'
|
||||||
|
icon={src}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
// key={resetFileKey}
|
||||||
|
ref={fileElement}
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||||
|
onChange={handleChange}
|
||||||
|
// disabled={disabled}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const BlockTypeFloatingToolbar = ({
|
const BlockTypeFloatingToolbar = ({
|
||||||
editor,
|
editor,
|
||||||
anchorElem,
|
anchorElem,
|
||||||
|
@ -112,6 +174,16 @@ const BlockTypeFloatingToolbar = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createImage = (src: string) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
|
||||||
|
const selectionNode = selection.anchor.getNode();
|
||||||
|
selectionNode.replace($createImageNode({ src }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={popupCharStylesEditorRef}
|
ref={popupCharStylesEditorRef}
|
||||||
|
@ -124,6 +196,7 @@ const BlockTypeFloatingToolbar = ({
|
||||||
aria-label='Insert horizontal line'
|
aria-label='Insert horizontal line'
|
||||||
icon={require('@tabler/icons/line-dashed.svg')}
|
icon={require('@tabler/icons/line-dashed.svg')}
|
||||||
/>
|
/>
|
||||||
|
<UploadButton onSelectFile={createImage} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue