diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 3a7b65f142..6de614d27d 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -33,6 +33,11 @@ const { CancelToken, isCancel } = axios; 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_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const; @@ -388,6 +393,102 @@ const submitComposeFail = (composeId: string, error: AxiosError) => ({ 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) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -436,7 +537,6 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => } // FIXME: Don't define const in loop - /* eslint-disable no-loop-func */ resizeImage(f).then(file => { const data = new FormData(); data.append('file', file); @@ -469,10 +569,37 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => } }); }).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) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -507,34 +634,6 @@ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosErro 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) => ({ type: COMPOSE_UPLOAD_UNDO, id: composeId, @@ -1001,6 +1100,11 @@ export { submitComposeRequest, submitComposeSuccess, submitComposeFail, + uploadFiles, + uploadFilesRequest, + uploadFilesSuccess, + uploadFilesProgress, + uploadFilesFail, uploadCompose, changeUploadCompose, changeUploadComposeRequest, diff --git a/app/soapbox/components/markup.css b/app/soapbox/components/markup.css index 48e292bcc1..2906deac13 100644 Binary files a/app/soapbox/components/markup.css and b/app/soapbox/components/markup.css differ diff --git a/app/soapbox/features/compose/components/upload-button.tsx b/app/soapbox/features/compose/components/upload-button.tsx index 6fe9ca33f8..82adc529d5 100644 --- a/app/soapbox/features/compose/components/upload-button.tsx +++ b/app/soapbox/features/compose/components/upload-button.tsx @@ -10,7 +10,7 @@ const messages = defineMessages({ upload: { id: 'upload_button.label', defaultMessage: 'Add media attachment' }, }); -const onlyImages = (types: ImmutableList) => { +export const onlyImages = (types: ImmutableList) => { return Boolean(types && types.every(type => type.startsWith('image/'))); }; diff --git a/app/soapbox/features/compose/editor/nodes/index.ts b/app/soapbox/features/compose/editor/nodes/index.ts index 04c206eb77..41448cf983 100644 --- a/app/soapbox/features/compose/editor/nodes/index.ts +++ b/app/soapbox/features/compose/editor/nodes/index.ts @@ -17,6 +17,7 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { useFeatures, useInstance } from 'soapbox/hooks'; import { EmojiNode } from './emoji-node'; +import { ImageNode } from './image-node'; import { MentionNode } from './mention-node'; 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_inline_images'])) nodes.push(ImageNode); return nodes; }; diff --git a/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx index 9f1b54aba2..0b443ec915 100644 --- a/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/floating-block-type-toolbar-plugin.tsx @@ -21,11 +21,73 @@ import { import { useCallback, useEffect, useRef, useState } from 'react'; import * as React from 'react'; 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 { ToolbarButton } from './floating-text-format-toolbar-plugin'; +import type { List as ImmutableList } from 'immutable'; + +interface IUploadButton { + onSelectFile: (src: string) => void +} + +const UploadButton: React.FC = ({ onSelectFile }) => { + const intl = useIntl(); + const { configuration } = useInstance(); + const dispatch = useAppDispatch(); + + const fileElement = useRef(null); + const attachmentTypes = configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList; + + const handleChange: React.ChangeEventHandler = (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 ( + + ); +}; + const BlockTypeFloatingToolbar = ({ editor, 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 (
+ )}