diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts
index 6de614d27..aa9b0fc48 100644
--- a/app/soapbox/actions/compose.ts
+++ b/app/soapbox/actions/compose.ts
@@ -11,12 +11,10 @@ import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features';
-import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
-import resizeImage from 'soapbox/utils/resize-image';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer';
-import { uploadMedia, fetchMedia, updateMedia } from './media';
+import { uploadFile, updateMedia } from './media';
import { openModal, closeModal } from './modals';
import { getSettings } from './settings';
import { createStatus } from './statuses';
@@ -33,11 +31,6 @@ 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;
@@ -96,9 +89,6 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const;
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
const messages = defineMessages({
- exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
- exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
- exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
@@ -393,109 +383,10 @@ 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;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
- 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 media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0);
@@ -513,62 +404,18 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
Array.from(files).forEach(async(f, i) => {
if (mediaCount + i > attachmentLimit - 1) return;
- 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(uploadComposeFail(composeId, true));
- return;
- } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
- const limit = formatBytes(maxVideoSize);
- const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
- toast.error(message);
- dispatch(uploadComposeFail(composeId, true));
- return;
- } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
- const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
- toast.error(message);
- dispatch(uploadComposeFail(composeId, 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) => {
+ dispatch(uploadFile(
+ f,
+ intl,
+ (data) => dispatch(uploadComposeSuccess(composeId, data, f)),
+ (error) => dispatch(uploadComposeFail(composeId, error)),
+ ({ loaded }: any) => {
progress[i] = loaded;
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
- };
+ },
+ (value) => total += value,
+ ));
- 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(uploadComposeSuccess(composeId, data, f));
- } else if (status === 202) {
- const poll = () => {
- dispatch(fetchMedia(data.id)).then(({ status, data }) => {
- if (status === 200) {
- dispatch(uploadComposeSuccess(composeId, data, f));
- } else if (status === 206) {
- setTimeout(() => poll(), 1000);
- }
- }).catch(error => dispatch(uploadComposeFail(composeId, error)));
- };
-
- poll();
- }
- });
- }).catch(error => dispatch(uploadComposeFail(composeId, error)));
});
};
@@ -1100,11 +947,7 @@ export {
submitComposeRequest,
submitComposeSuccess,
submitComposeFail,
- uploadFiles,
- uploadFilesRequest,
- uploadFilesSuccess,
- uploadFilesProgress,
- uploadFilesFail,
+ uploadFile,
uploadCompose,
changeUploadCompose,
changeUploadComposeRequest,
diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts
index 6cf6fee50..6d62d4585 100644
--- a/app/soapbox/actions/events.ts
+++ b/app/soapbox/actions/events.ts
@@ -2,11 +2,9 @@ import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api';
import toast from 'soapbox/toast';
-import { formatBytes } from 'soapbox/utils/media';
-import resizeImage from 'soapbox/utils/resize-image';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
-import { fetchMedia, uploadMedia } from './media';
+import { uploadFile } from './media';
import { closeModal, openModal } from './modals';
import {
STATUS_FETCH_SOURCE_FAIL,
@@ -154,52 +152,21 @@ const changeEditEventLocation = (value: string | null) =>
};
const uploadEventBanner = (file: File, intl: IntlShape) =>
- (dispatch: AppDispatch, getState: () => RootState) => {
- const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
-
+ (dispatch: AppDispatch) => {
let progress = 0;
dispatch(uploadEventBannerRequest());
- if (maxImageSize && (file.size > maxImageSize)) {
- const limit = formatBytes(maxImageSize);
- const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
- toast.error(message);
- dispatch(uploadEventBannerFail(true));
- return;
- }
-
- resizeImage(file).then(file => {
- const data = new FormData();
- data.append('file', file);
- // Account for disparity in size of original image and resized data
-
- const onUploadProgress = ({ loaded }: any) => {
+ dispatch(uploadFile(
+ file,
+ intl,
+ (data) => dispatch(uploadEventBannerSuccess(data, file)),
+ (error) => dispatch(uploadEventBannerFail(error)),
+ ({ loaded }: any) => {
progress = loaded;
dispatch(uploadEventBannerProgress(progress));
- };
-
- 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(uploadEventBannerSuccess(data, file));
- } else if (status === 202) {
- const poll = () => {
- dispatch(fetchMedia(data.id)).then(({ status, data }) => {
- if (status === 200) {
- dispatch(uploadEventBannerSuccess(data, file));
- } else if (status === 206) {
- setTimeout(() => poll(), 1000);
- }
- }).catch(error => dispatch(uploadEventBannerFail(error)));
- };
-
- poll();
- }
- });
- }).catch(error => dispatch(uploadEventBannerFail(error)));
+ },
+ ));
};
const uploadEventBannerRequest = () => ({
diff --git a/app/soapbox/actions/media.ts b/app/soapbox/actions/media.ts
index 15c637c35..112d0e02b 100644
--- a/app/soapbox/actions/media.ts
+++ b/app/soapbox/actions/media.ts
@@ -1,8 +1,22 @@
+import { defineMessages, type IntlShape } from 'react-intl';
+
+import toast from 'soapbox/toast';
+import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
+import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
+import resizeImage from 'soapbox/utils/resize-image';
import api from '../api';
+import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
+import type { APIEntity } from 'soapbox/types/entities';
+
+const messages = defineMessages({
+ exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
+ exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
+ exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
+});
const noOp = (e: any) => {};
@@ -41,10 +55,78 @@ const uploadMedia = (data: FormData, onUploadProgress = noOp) =>
}
};
+const uploadFile = (
+ file: File,
+ intl: IntlShape,
+ onSuccess: (data: APIEntity) => void = () => {},
+ onFail: (error: AxiosError | true) => void = () => {},
+ onProgress: (loaded: number) => void = () => {},
+ changeTotal: (value: number) => void = () => {},
+) =>
+ async (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 isImage = file.type.match(/image.*/);
+ const isVideo = file.type.match(/video.*/);
+ const videoDurationInSeconds = (isVideo && maxVideoDuration) ? await getVideoDuration(file) : 0;
+
+ if (isImage && maxImageSize && (file.size > maxImageSize)) {
+ const limit = formatBytes(maxImageSize);
+ const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
+ toast.error(message);
+ onFail(true);
+ return;
+ } else if (isVideo && maxVideoSize && (file.size > maxVideoSize)) {
+ const limit = formatBytes(maxVideoSize);
+ const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
+ toast.error(message);
+ onFail(true);
+ return;
+ } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
+ const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
+ toast.error(message);
+ onFail(true);
+ return;
+ }
+
+ // FIXME: Don't define const in loop
+ resizeImage(file).then(resized => {
+ const data = new FormData();
+ data.append('file', resized);
+ // Account for disparity in size of original image and resized data
+ changeTotal(resized.size - file.size);
+
+ return dispatch(uploadMedia(data, onProgress))
+ .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) {
+ onSuccess(data);
+ } else if (status === 202) {
+ const poll = () => {
+ dispatch(fetchMedia(data.id)).then(({ status, data }) => {
+ if (status === 200) {
+ onSuccess(data);
+ } else if (status === 206) {
+ setTimeout(() => poll(), 1000);
+ }
+ }).catch(error => onFail(error));
+ };
+
+ poll();
+ }
+ });
+ }).catch(error => onFail(error));
+ };
+
export {
fetchMedia,
updateMedia,
uploadMediaV1,
uploadMediaV2,
uploadMedia,
+ uploadFile,
};
diff --git a/app/soapbox/features/compose/editor/nodes/image-component.tsx b/app/soapbox/features/compose/editor/nodes/image-component.tsx
new file mode 100644
index 000000000..634095bcd
--- /dev/null
+++ b/app/soapbox/features/compose/editor/nodes/image-component.tsx
@@ -0,0 +1,253 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
+import { mergeRegister } from '@lexical/utils';
+import {
+ $getNodeByKey,
+ $getSelection,
+ $isNodeSelection,
+ $setSelection,
+ CLICK_COMMAND,
+ COMMAND_PRIORITY_LOW,
+ DRAGSTART_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ SELECTION_CHANGE_COMMAND,
+} from 'lexical';
+import * as React from 'react';
+import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
+
+import { $isImageNode } from './image-node';
+
+import type {
+ GridSelection,
+ LexicalEditor,
+ NodeKey,
+ NodeSelection,
+ RangeSelection,
+} from 'lexical';
+
+const imageCache = new Set();
+
+function useSuspenseImage(src: string) {
+ if (!imageCache.has(src)) {
+ throw new Promise((resolve) => {
+ const img = new Image();
+ img.src = src;
+ img.onload = () => {
+ imageCache.add(src);
+ resolve(null);
+ };
+ });
+ }
+}
+
+function LazyImage({
+ altText,
+ className,
+ imageRef,
+ src,
+}: {
+ altText: string
+ className: string | null
+ imageRef: {current: null | HTMLImageElement}
+ src: string
+}): JSX.Element {
+ useSuspenseImage(src);
+ return (
+
+ );
+}
+
+export default function ImageComponent({
+ src,
+ altText,
+ nodeKey,
+}: {
+ altText: string
+ nodeKey: NodeKey
+ src: string
+}): JSX.Element {
+ const imageRef = useRef(null);
+ const buttonRef = useRef(null);
+ const [isSelected, setSelected, clearSelection] =
+ useLexicalNodeSelection(nodeKey);
+ const [editor] = useLexicalComposerContext();
+ const [selection, setSelection] = useState<
+ RangeSelection | NodeSelection | GridSelection | null
+ >(null);
+ const activeEditorRef = useRef(null);
+
+ const onDelete = useCallback(
+ (payload: KeyboardEvent) => {
+ if (isSelected && $isNodeSelection($getSelection())) {
+ const event: KeyboardEvent = payload;
+ event.preventDefault();
+ const node = $getNodeByKey(nodeKey);
+ if ($isImageNode(node)) {
+ node.remove();
+ }
+ }
+ return false;
+ },
+ [isSelected, nodeKey],
+ );
+
+ const onEnter = useCallback(
+ (event: KeyboardEvent) => {
+ const latestSelection = $getSelection();
+ const buttonElem = buttonRef.current;
+ if (
+ isSelected &&
+ $isNodeSelection(latestSelection) &&
+ latestSelection.getNodes().length === 1
+ ) {
+ if (
+ buttonElem !== null &&
+ buttonElem !== document.activeElement
+ ) {
+ event.preventDefault();
+ buttonElem.focus();
+ return true;
+ }
+ }
+ return false;
+ },
+ [isSelected],
+ );
+
+ const onEscape = useCallback(
+ (event: KeyboardEvent) => {
+ if (buttonRef.current === event.target) {
+ $setSelection(null);
+ editor.update(() => {
+ setSelected(true);
+ const parentRootElement = editor.getRootElement();
+ if (parentRootElement !== null) {
+ parentRootElement.focus();
+ }
+ });
+ return true;
+ }
+ return false;
+ },
+ [editor, setSelected],
+ );
+
+ useEffect(() => {
+ let isMounted = true;
+ const unregister = mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ if (isMounted) {
+ setSelection(editorState.read(() => $getSelection()));
+ }
+ }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_, activeEditor) => {
+ activeEditorRef.current = activeEditor;
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ CLICK_COMMAND,
+ (payload) => {
+ const event = payload;
+
+ if (event.target === imageRef.current) {
+ if (event.shiftKey) {
+ setSelected(!isSelected);
+ } else {
+ clearSelection();
+ setSelected(true);
+ }
+ return true;
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ DRAGSTART_COMMAND,
+ (event) => {
+ if (event.target === imageRef.current) {
+ // TODO This is just a temporary workaround for FF to behave like other browsers.
+ // Ideally, this handles drag & drop too (and all browsers).
+ event.preventDefault();
+ return true;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ KEY_DELETE_COMMAND,
+ onDelete,
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ KEY_BACKSPACE_COMMAND,
+ onDelete,
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ onEscape,
+ COMMAND_PRIORITY_LOW,
+ ),
+ );
+ return () => {
+ isMounted = false;
+ unregister();
+ };
+ }, [
+ clearSelection,
+ editor,
+ isSelected,
+ nodeKey,
+ onDelete,
+ onEnter,
+ onEscape,
+ setSelected,
+ ]);
+
+ const draggable = isSelected && $isNodeSelection(selection);
+ const isFocused = isSelected;
+ return (
+
+ <>
+
+
+
+ >
+
+ );
+}
diff --git a/app/soapbox/features/compose/editor/nodes/image-node.tsx b/app/soapbox/features/compose/editor/nodes/image-node.tsx
new file mode 100644
index 000000000..85bb7dad9
--- /dev/null
+++ b/app/soapbox/features/compose/editor/nodes/image-node.tsx
@@ -0,0 +1,173 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { $applyNodeReplacement, DecoratorNode } from 'lexical';
+import * as React from 'react';
+import { Suspense } from 'react';
+
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ LexicalNode,
+ NodeKey,
+ SerializedLexicalNode,
+ Spread,
+} from 'lexical';
+
+const ImageComponent = React.lazy(() => import('./image-component'));
+
+interface ImagePayload {
+ altText?: string
+ key?: NodeKey
+ src: string
+}
+
+function convertImageElement(domNode: Node): null | DOMConversionOutput {
+ if (domNode instanceof HTMLImageElement) {
+ const { alt: altText, src } = domNode;
+ const node = $createImageNode({ altText, src });
+ return { node };
+ }
+ return null;
+}
+
+type SerializedImageNode = Spread<
+ {
+ altText: string
+ src: string
+ },
+ SerializedLexicalNode
+>;
+
+class ImageNode extends DecoratorNode {
+
+ __src: string;
+ __altText: string;
+
+ static getType(): string {
+ return 'image';
+ }
+
+ static clone(node: ImageNode): ImageNode {
+ return new ImageNode(
+ node.__src,
+ node.__altText,
+ node.__key,
+ );
+ }
+
+ static importJSON(serializedNode: SerializedImageNode): ImageNode {
+ const { altText, src } =
+ serializedNode;
+ const node = $createImageNode({
+ altText,
+ src,
+ });
+ return node;
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('img');
+ element.setAttribute('src', this.__src);
+ element.setAttribute('alt', this.__altText);
+ return { element };
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ img: (node: Node) => ({
+ conversion: convertImageElement,
+ priority: 0,
+ }),
+ };
+ }
+
+ constructor(
+ src: string,
+ altText: string,
+ key?: NodeKey,
+ ) {
+ super(key);
+ this.__src = src;
+ this.__altText = altText;
+ }
+
+ exportJSON(): SerializedImageNode {
+ return {
+ altText: this.getAltText(),
+ src: this.getSrc(),
+ type: 'image',
+ version: 1,
+ };
+ }
+
+ // View
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const span = document.createElement('span');
+ const theme = config.theme;
+ const className = theme.image;
+ if (className !== undefined) {
+ span.className = className;
+ }
+ return span;
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ getSrc(): string {
+ return this.__src;
+ }
+
+ getAltText(): string {
+ return this.__altText;
+ }
+
+ decorate(): JSX.Element {
+ return (
+
+
+
+ );
+ }
+
+}
+
+function $createImageNode({
+ altText = '',
+ src,
+ key,
+}: ImagePayload): ImageNode {
+ return $applyNodeReplacement(
+ new ImageNode(
+ src,
+ altText,
+ key,
+ ),
+ );
+}
+
+const $isImageNode = (
+ node: LexicalNode | null | undefined,
+): node is ImageNode => node instanceof ImageNode;
+
+export {
+ type ImagePayload,
+ type SerializedImageNode,
+ ImageNode,
+ $createImageNode,
+ $isImageNode,
+};
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 0b443ec91..95d3fe6d7 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
@@ -23,7 +23,7 @@ import * as React from 'react';
import { createPortal } from 'react-dom';
import { useIntl } from 'react-intl';
-import { uploadFiles } from 'soapbox/actions/compose';
+import { uploadFile } from 'soapbox/actions/compose';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { onlyImages } from '../../components/upload-button';
@@ -49,7 +49,11 @@ const UploadButton: React.FC = ({ onSelectFile }) => {
const handleChange: React.ChangeEventHandler = (e) => {
if (e.target.files?.length) {
// @ts-ignore
- dispatch(uploadFiles([e.target.files.item(0)] as any, intl));
+ dispatch(uploadFile(
+ e.target.files.item(0) as File,
+ intl,
+ ({ url }) => onSelectFile(url),
+ ));
}
};