diff --git a/app/soapbox/components/media-gallery.tsx b/app/soapbox/components/media-gallery.tsx index 3e7b32bbc..cd7cd2605 100644 --- a/app/soapbox/components/media-gallery.tsx +++ b/app/soapbox/components/media-gallery.tsx @@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect } from 'react'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still-image'; -import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload'; +import { MIMETYPE_ICONS } from 'soapbox/components/upload'; import { useSettings } from 'soapbox/hooks'; import { Attachment } from 'soapbox/types/entities'; import { truncateFilename } from 'soapbox/utils/media'; diff --git a/app/soapbox/components/upload.tsx b/app/soapbox/components/upload.tsx new file mode 100644 index 000000000..85f136378 --- /dev/null +++ b/app/soapbox/components/upload.tsx @@ -0,0 +1,213 @@ +import classNames from 'clsx'; +import { List as ImmutableList } from 'immutable'; +import React, { useState } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { spring } from 'react-motion'; + +import { openModal } from 'soapbox/actions/modals'; +import Blurhash from 'soapbox/components/blurhash'; +import Icon from 'soapbox/components/icon'; +import IconButton from 'soapbox/components/icon-button'; +import Motion from 'soapbox/features/ui/util/optional-motion'; +import { useAppDispatch } from 'soapbox/hooks'; +import { Attachment } from 'soapbox/types/entities'; + +const bookIcon = require('@tabler/icons/book.svg'); +const fileCodeIcon = require('@tabler/icons/file-code.svg'); +const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg'); +const fileTextIcon = require('@tabler/icons/file-text.svg'); +const fileZipIcon = require('@tabler/icons/file-zip.svg'); +const defaultIcon = require('@tabler/icons/paperclip.svg'); +const presentationIcon = require('@tabler/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': fileSpreadsheetIcon, + 'application/vnd.ms-excel': fileSpreadsheetIcon, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileSpreadsheetIcon, + '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: Attachment, + onSubmit?(): void, + onDelete?(): void, + onDescriptionChange?(description: string): void, + descriptionLimit?: number, + withPreview?: boolean, +} + +const Upload: React.FC = ({ + media, + onSubmit, + onDelete, + onDescriptionChange, + descriptionLimit, + withPreview = true, +}) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const [dirtyDescription, setDirtyDescription] = useState(null); + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + handleInputBlur(); + onSubmit(); + } + }; + + const handleUndoClick: React.MouseEventHandler = e => { + if (onDelete) { + e.stopPropagation(); + onDelete(); + } + }; + + 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 && onDescriptionChange) { + onDescriptionChange(dirtyDescription); + } + }; + + const handleOpenModal = () => { + dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 })); + }; + + const active = hovered || focused; + const description = dirtyDescription || (dirtyDescription !== '' && media.description) || ''; + const focusX = media.meta.getIn(['focus', 'x']) as number | undefined; + const focusY = media.meta.getIn(['focus', 'y']) as number | undefined; + const x = focusX ? ((focusX / 2) + .5) * 100 : undefined; + const y = focusY ? ((focusY / -2) + .5) * 100 : undefined; + const mediaType = media.type; + const mimeType = media.pleroma.get('mime_type') as string | undefined; + + const uploadIcon = mediaType === 'unknown' && ( + + ); + + return ( +
+ + + {({ scale }) => ( +
+
+ {onDelete && ( + } + /> + )} + + {/* Only display the "Preview" button for a valid attachment with a URL */} + {(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && ( + } + /> + )} +
+ + {onDescriptionChange && ( +
+