diff --git a/app/soapbox/features/compose/components/upload.js b/app/soapbox/features/compose/components/upload.js
deleted file mode 100644
index a293509f9..000000000
--- a/app/soapbox/features/compose/components/upload.js
+++ /dev/null
@@ -1,212 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import spring from 'react-motion/lib/spring';
-import { withRouter } from 'react-router-dom';
-
-import Blurhash from 'soapbox/components/blurhash';
-import Icon from 'soapbox/components/icon';
-import IconButton from 'soapbox/components/icon_button';
-
-import Motion from '../../ui/util/optional_motion';
-
-const bookIcon = require('@tabler/icons/icons/book.svg');
-const fileAnalyticsIcon = require('@tabler/icons/icons/file-analytics.svg');
-const fileCodeIcon = require('@tabler/icons/icons/file-code.svg');
-const fileTextIcon = require('@tabler/icons/icons/file-text.svg');
-const fileZipIcon = require('@tabler/icons/icons/file-zip.svg');
-const presentationIcon = require('@tabler/icons/icons/presentation.svg');
-
-export const MIMETYPE_ICONS = {
- 'application/x-freearc': fileZipIcon,
- 'application/x-bzip': fileZipIcon,
- 'application/x-bzip2': fileZipIcon,
- 'application/gzip': fileZipIcon,
- 'application/vnd.rar': fileZipIcon,
- 'application/x-tar': fileZipIcon,
- 'application/zip': fileZipIcon,
- 'application/x-7z-compressed': fileZipIcon,
- 'application/x-csh': fileCodeIcon,
- 'application/html': fileCodeIcon,
- 'text/javascript': fileCodeIcon,
- 'application/json': fileCodeIcon,
- 'application/ld+json': fileCodeIcon,
- 'application/x-httpd-php': fileCodeIcon,
- 'application/x-sh': fileCodeIcon,
- 'application/xhtml+xml': fileCodeIcon,
- 'application/xml': fileCodeIcon,
- 'application/epub+zip': bookIcon,
- 'application/vnd.oasis.opendocument.spreadsheet': fileAnalyticsIcon,
- 'application/vnd.ms-excel': fileAnalyticsIcon,
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileAnalyticsIcon,
- 'application/pdf': fileTextIcon,
- 'application/vnd.oasis.opendocument.presentation': presentationIcon,
- 'application/vnd.ms-powerpoint': presentationIcon,
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': presentationIcon,
- 'text/plain': fileTextIcon,
- 'application/rtf': fileTextIcon,
- 'application/msword': fileTextIcon,
- 'application/x-abiword': fileTextIcon,
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
- 'application/vnd.oasis.opendocument.text': fileTextIcon,
-};
-
-const messages = defineMessages({
- description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
- delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
-});
-
-export default @injectIntl @withRouter
-class Upload extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- onUndo: PropTypes.func.isRequired,
- onDescriptionChange: PropTypes.func.isRequired,
- onOpenFocalPoint: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- history: PropTypes.object.isRequired,
- };
-
- state = {
- hovered: false,
- focused: false,
- dirtyDescription: null,
- };
-
- handleKeyDown = (e) => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- this.handleSubmit();
- }
- }
-
- handleSubmit = () => {
- this.handleInputBlur();
- this.props.onSubmit(this.props.history);
- }
-
- handleUndoClick = e => {
- e.stopPropagation();
- this.props.onUndo(this.props.media.get('id'));
- }
-
- handleFocalPointClick = e => {
- e.stopPropagation();
- this.props.onOpenFocalPoint(this.props.media.get('id'));
- }
-
- handleInputChange = e => {
- this.setState({ dirtyDescription: e.target.value });
- }
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- }
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- }
-
- handleInputFocus = () => {
- this.setState({ focused: true });
- }
-
- handleClick = () => {
- this.setState({ focused: true });
- }
-
- handleInputBlur = () => {
- const { dirtyDescription } = this.state;
-
- this.setState({ focused: false, dirtyDescription: null });
-
- if (dirtyDescription !== null) {
- this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
- }
- }
-
- handleOpenModal = () => {
- this.props.onOpenModal(this.props.media);
- }
-
- render() {
- const { intl, media, descriptionLimit } = this.props;
- const active = this.state.hovered || this.state.focused;
- const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
- const focusX = media.getIn(['meta', 'focus', 'x']);
- const focusY = media.getIn(['meta', 'focus', 'y']);
- const x = ((focusX / 2) + .5) * 100;
- const y = ((focusY / -2) + .5) * 100;
- const mediaType = media.get('type');
- const uploadIcon = mediaType === 'unknown' && (
-
- );
-
- return (
-
-
-
- {({ scale }) => (
-
-
- }
- />
-
- {/* Only display the "Preview" button for a valid attachment with a URL */}
- {(mediaType !== 'unknown' && Boolean(media.get('url'))) && (
- }
- />
- )}
-
-
-
-
-
-
-
- {mediaType === 'video' && (
-
- )}
- {uploadIcon}
-
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/soapbox/features/compose/components/upload.tsx b/app/soapbox/features/compose/components/upload.tsx
new file mode 100644
index 000000000..b60079dca
--- /dev/null
+++ b/app/soapbox/features/compose/components/upload.tsx
@@ -0,0 +1,205 @@
+import classNames from 'classnames';
+import React, { useState } from 'react';
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+import { spring } from 'react-motion';
+import { useHistory } from 'react-router-dom';
+
+import Blurhash from 'soapbox/components/blurhash';
+import Icon from 'soapbox/components/icon';
+import IconButton from 'soapbox/components/icon_button';
+
+import Motion from '../../ui/util/optional_motion';
+
+import type { Map as ImmutableMap } from 'immutable';
+
+const bookIcon = require('@tabler/icons/icons/book.svg');
+const fileAnalyticsIcon = require('@tabler/icons/icons/file-analytics.svg');
+const fileCodeIcon = require('@tabler/icons/icons/file-code.svg');
+const fileTextIcon = require('@tabler/icons/icons/file-text.svg');
+const fileZipIcon = require('@tabler/icons/icons/file-zip.svg');
+const defaultIcon = require('@tabler/icons/icons/paperclip.svg');
+const presentationIcon = require('@tabler/icons/icons/presentation.svg');
+
+export const MIMETYPE_ICONS: Record = {
+ 'application/x-freearc': fileZipIcon,
+ 'application/x-bzip': fileZipIcon,
+ 'application/x-bzip2': fileZipIcon,
+ 'application/gzip': fileZipIcon,
+ 'application/vnd.rar': fileZipIcon,
+ 'application/x-tar': fileZipIcon,
+ 'application/zip': fileZipIcon,
+ 'application/x-7z-compressed': fileZipIcon,
+ 'application/x-csh': fileCodeIcon,
+ 'application/html': fileCodeIcon,
+ 'text/javascript': fileCodeIcon,
+ 'application/json': fileCodeIcon,
+ 'application/ld+json': fileCodeIcon,
+ 'application/x-httpd-php': fileCodeIcon,
+ 'application/x-sh': fileCodeIcon,
+ 'application/xhtml+xml': fileCodeIcon,
+ 'application/xml': fileCodeIcon,
+ 'application/epub+zip': bookIcon,
+ 'application/vnd.oasis.opendocument.spreadsheet': fileAnalyticsIcon,
+ 'application/vnd.ms-excel': fileAnalyticsIcon,
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileAnalyticsIcon,
+ 'application/pdf': fileTextIcon,
+ 'application/vnd.oasis.opendocument.presentation': presentationIcon,
+ 'application/vnd.ms-powerpoint': presentationIcon,
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': presentationIcon,
+ 'text/plain': fileTextIcon,
+ 'application/rtf': fileTextIcon,
+ 'application/msword': fileTextIcon,
+ 'application/x-abiword': fileTextIcon,
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
+ 'application/vnd.oasis.opendocument.text': fileTextIcon,
+};
+
+const messages = defineMessages({
+ description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+ delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
+});
+
+interface IUpload {
+ media: ImmutableMap,
+ descriptionLimit: number,
+ onUndo: (attachmentId: string) => void,
+ onDescriptionChange: (attachmentId: string, description: string) => void,
+ onOpenFocalPoint: (attachmentId: string) => void,
+ onOpenModal: (attachments: ImmutableMap) => void,
+ onSubmit: (history: ReturnType) => void,
+}
+
+const Upload: React.FC = (props) => {
+ const intl = useIntl();
+ const history = useHistory();
+
+ const [hovered, setHovered] = useState(false);
+ const [focused, setFocused] = useState(false);
+ const [dirtyDescription, setDirtyDescription] = useState(null);
+
+ const handleKeyDown: React.KeyboardEventHandler = (e) => {
+ if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ handleSubmit();
+ }
+ };
+
+ const handleSubmit = () => {
+ handleInputBlur();
+ props.onSubmit(history);
+ };
+
+ const handleUndoClick: React.MouseEventHandler = e => {
+ e.stopPropagation();
+ props.onUndo(props.media.get('id'));
+ };
+
+ const handleInputChange: React.ChangeEventHandler = e => {
+ setDirtyDescription(e.target.value);
+ };
+
+ const handleMouseEnter = () => {
+ setHovered(true);
+ };
+
+ const handleMouseLeave = () => {
+ setHovered(false);
+ };
+
+ const handleInputFocus = () => {
+ setFocused(true);
+ };
+
+ const handleClick = () => {
+ setFocused(true);
+ };
+
+ const handleInputBlur = () => {
+ setFocused(false);
+ setDirtyDescription(null);
+
+ if (dirtyDescription !== null) {
+ props.onDescriptionChange(props.media.get('id'), dirtyDescription);
+ }
+ };
+
+ const handleOpenModal = () => {
+ props.onOpenModal(props.media);
+ };
+
+ const active = hovered || focused;
+ const description = dirtyDescription || (dirtyDescription !== '' && props.media.get('description')) || '';
+ const focusX = props.media.getIn(['meta', 'focus', 'x']) as number | undefined;
+ const focusY = props.media.getIn(['meta', 'focus', 'y']) as number | undefined;
+ const x = focusX ? ((focusX / 2) + .5) * 100 : 0;
+ const y = focusY ? ((focusY / -2) + .5) * 100 : 0;
+ const mediaType = props.media.get('type');
+ const mimeType = props.media.getIn(['pleroma', 'mime_type']) as string | undefined;
+
+ const uploadIcon = mediaType === 'unknown' && (
+
+ );
+
+ return (
+
+
+
+ {({ scale }) => (
+
+
+ }
+ />
+
+ {/* Only display the "Preview" button for a valid attachment with a URL */}
+ {(mediaType !== 'unknown' && Boolean(props.media.get('url'))) && (
+ }
+ />
+ )}
+
+
+
+
+
+
+
+ {mediaType === 'video' && (
+
+ )}
+ {uploadIcon}
+
+
+ )}
+
+
+ );
+};
+
+export default Upload;