diff --git a/app/javascript/mastodon/features/compose/components/upload.jsx b/app/javascript/mastodon/features/compose/components/upload.jsx
deleted file mode 100644
index 7f6ef6cfd8..0000000000
--- a/app/javascript/mastodon/features/compose/components/upload.jsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import PropTypes from 'prop-types';
-import { useCallback } from 'react';
-
-import { FormattedMessage } from 'react-intl';
-
-import classNames from 'classnames';
-
-import { useDispatch, useSelector } from 'react-redux';
-
-import spring from 'react-motion/lib/spring';
-
-import CloseIcon from '@/material-icons/400-20px/close.svg?react';
-import EditIcon from '@/material-icons/400-24px/edit.svg?react';
-import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
-import { undoUploadCompose, initMediaEditModal } from 'mastodon/actions/compose';
-import { Blurhash } from 'mastodon/components/blurhash';
-import { Icon } from 'mastodon/components/icon';
-import Motion from 'mastodon/features/ui/util/optional_motion';
-
-export const Upload = ({ id, onDragStart, onDragEnter, onDragEnd }) => {
- const dispatch = useDispatch();
- const media = useSelector(state => state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id));
- const sensitive = useSelector(state => state.getIn(['compose', 'spoiler']));
-
- const handleUndoClick = useCallback(() => {
- dispatch(undoUploadCompose(id));
- }, [dispatch, id]);
-
- const handleFocalPointClick = useCallback(() => {
- dispatch(initMediaEditModal(id));
- }, [dispatch, id]);
-
- const handleDragStart = useCallback(() => {
- onDragStart(id);
- }, [onDragStart, id]);
-
- const handleDragEnter = useCallback(() => {
- onDragEnter(id);
- }, [onDragEnter, id]);
-
- if (!media) {
- return null;
- }
-
- 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 missingDescription = (media.get('description') || '').length === 0;
-
- return (
-
-
- {({ scale }) => (
-
- {sensitive &&
}
-
-
-
-
-
-
-
-
-
-
- )}
-
-
- );
-};
-
-Upload.propTypes = {
- id: PropTypes.string,
- onDragEnter: PropTypes.func,
- onDragStart: PropTypes.func,
- onDragEnd: PropTypes.func,
-};
diff --git a/app/javascript/mastodon/features/compose/components/upload.tsx b/app/javascript/mastodon/features/compose/components/upload.tsx
new file mode 100644
index 0000000000..3a24b28291
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload.tsx
@@ -0,0 +1,130 @@
+import { useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+import CloseIcon from '@/material-icons/400-20px/close.svg?react';
+import EditIcon from '@/material-icons/400-24px/edit.svg?react';
+import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
+import {
+ undoUploadCompose,
+ initMediaEditModal,
+} from 'mastodon/actions/compose';
+import { Blurhash } from 'mastodon/components/blurhash';
+import { Icon } from 'mastodon/components/icon';
+import type { MediaAttachment } from 'mastodon/models/media_attachment';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+export const Upload: React.FC<{
+ id: string;
+ dragging?: boolean;
+ overlay?: boolean;
+ tall?: boolean;
+ wide?: boolean;
+}> = ({ id, dragging, overlay, tall, wide }) => {
+ const dispatch = useAppDispatch();
+ const media = useAppSelector(
+ (state) =>
+ state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
+ .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+ .find((item: MediaAttachment) => item.get('id') === id) as // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+ | MediaAttachment
+ | undefined,
+ );
+ const sensitive = useAppSelector(
+ (state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+ );
+
+ const handleUndoClick = useCallback(() => {
+ dispatch(undoUploadCompose(id));
+ }, [dispatch, id]);
+
+ const handleFocalPointClick = useCallback(() => {
+ dispatch(initMediaEditModal(id));
+ }, [dispatch, id]);
+
+ const { attributes, listeners, setNodeRef, transform, transition } =
+ useSortable({ id });
+
+ if (!media) {
+ return null;
+ }
+
+ const focusX = media.getIn(['meta', 'focus', 'x']) as number;
+ const focusY = media.getIn(['meta', 'focus', 'y']) as number;
+ const x = (focusX / 2 + 0.5) * 100;
+ const y = (focusY / -2 + 0.5) * 100;
+ const missingDescription =
+ ((media.get('description') as string | undefined) ?? '').length === 0;
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+ {sensitive && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.jsx b/app/javascript/mastodon/features/compose/components/upload_form.jsx
deleted file mode 100644
index adf5591382..0000000000
--- a/app/javascript/mastodon/features/compose/components/upload_form.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useRef, useCallback } from 'react';
-
-import { useSelector, useDispatch } from 'react-redux';
-
-import { changeMediaOrder } from 'mastodon/actions/compose';
-
-import { Upload } from './upload';
-import { UploadProgress } from './upload_progress';
-
-export const UploadForm = () => {
- const dispatch = useDispatch();
- const mediaIds = useSelector(state => state.getIn(['compose', 'media_attachments']).map(item => item.get('id')));
- const active = useSelector(state => state.getIn(['compose', 'is_uploading']));
- const progress = useSelector(state => state.getIn(['compose', 'progress']));
- const isProcessing = useSelector(state => state.getIn(['compose', 'is_processing']));
-
- const dragItem = useRef();
- const dragOverItem = useRef();
-
- const handleDragStart = useCallback(id => {
- dragItem.current = id;
- }, [dragItem]);
-
- const handleDragEnter = useCallback(id => {
- dragOverItem.current = id;
- }, [dragOverItem]);
-
- const handleDragEnd = useCallback(() => {
- dispatch(changeMediaOrder(dragItem.current, dragOverItem.current));
- dragItem.current = null;
- dragOverItem.current = null;
- }, [dispatch, dragItem, dragOverItem]);
-
- return (
- <>
-
-
- {mediaIds.size > 0 && (
-
- {mediaIds.map(id => (
-
- ))}
-
- )}
- >
- );
-};
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.tsx b/app/javascript/mastodon/features/compose/components/upload_form.tsx
new file mode 100644
index 0000000000..1c01c2bd9c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_form.tsx
@@ -0,0 +1,185 @@
+import { useState, useCallback, useMemo } from 'react';
+
+import { useIntl, defineMessages } from 'react-intl';
+
+import type { List } from 'immutable';
+
+import type {
+ DragStartEvent,
+ DragEndEvent,
+ UniqueIdentifier,
+ Announcements,
+ ScreenReaderInstructions,
+} from '@dnd-kit/core';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragOverlay,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ rectSortingStrategy,
+} from '@dnd-kit/sortable';
+
+import { changeMediaOrder } from 'mastodon/actions/compose';
+import type { MediaAttachment } from 'mastodon/models/media_attachment';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+import { Upload } from './upload';
+import { UploadProgress } from './upload_progress';
+
+const messages = defineMessages({
+ screenReaderInstructions: {
+ id: 'upload_form.drag_and_drop.instructions',
+ defaultMessage:
+ 'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.',
+ },
+ onDragStart: {
+ id: 'upload_form.drag_and_drop.on_drag_start',
+ defaultMessage: 'Picked up media attachment {item}.',
+ },
+ onDragOver: {
+ id: 'upload_form.drag_and_drop.on_drag_over',
+ defaultMessage: 'Media attachment {item} was moved.',
+ },
+ onDragEnd: {
+ id: 'upload_form.drag_and_drop.on_drag_end',
+ defaultMessage: 'Media attachment {item} was dropped.',
+ },
+ onDragCancel: {
+ id: 'upload_form.drag_and_drop.on_drag_cancel',
+ defaultMessage:
+ 'Dragging was cancelled. Media attachment {item} was dropped.',
+ },
+});
+
+export const UploadForm: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+ const mediaIds = useAppSelector(
+ (state) =>
+ state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
+ .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+ .map((item: MediaAttachment) => item.get('id')) as List, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+ );
+ const active = useAppSelector(
+ (state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+ );
+ const progress = useAppSelector(
+ (state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+ );
+ const isProcessing = useAppSelector(
+ (state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+ );
+ const [activeId, setActiveId] = useState(null);
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 5,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ const handleDragStart = useCallback(
+ (e: DragStartEvent) => {
+ const { active } = e;
+
+ setActiveId(active.id);
+ },
+ [setActiveId],
+ );
+
+ const handleDragEnd = useCallback(
+ (e: DragEndEvent) => {
+ const { active, over } = e;
+
+ if (over && active.id !== over.id) {
+ dispatch(changeMediaOrder(active.id, over.id));
+ }
+
+ setActiveId(null);
+ },
+ [dispatch, setActiveId],
+ );
+
+ const accessibility: {
+ screenReaderInstructions: ScreenReaderInstructions;
+ announcements: Announcements;
+ } = useMemo(
+ () => ({
+ screenReaderInstructions: {
+ draggable: intl.formatMessage(messages.screenReaderInstructions),
+ },
+
+ announcements: {
+ onDragStart({ active }) {
+ return intl.formatMessage(messages.onDragStart, { item: active.id });
+ },
+
+ onDragOver({ active }) {
+ return intl.formatMessage(messages.onDragOver, { item: active.id });
+ },
+
+ onDragEnd({ active }) {
+ return intl.formatMessage(messages.onDragEnd, { item: active.id });
+ },
+
+ onDragCancel({ active }) {
+ return intl.formatMessage(messages.onDragCancel, { item: active.id });
+ },
+ },
+ }),
+ [intl],
+ );
+
+ return (
+ <>
+
+
+ {mediaIds.size > 0 && (
+
+
+
+ {mediaIds.map((id, idx) => (
+
+ ))}
+
+
+
+ {activeId ? : null}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index e86e300bcb..1e78745359 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -852,6 +852,11 @@
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
"upload_form.description": "Describe for people who are blind or have low vision",
+ "upload_form.drag_and_drop.instructions": "To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.",
+ "upload_form.drag_and_drop.on_drag_cancel": "Dragging was cancelled. Media attachment {item} was dropped.",
+ "upload_form.drag_and_drop.on_drag_end": "Media attachment {item} was dropped.",
+ "upload_form.drag_and_drop.on_drag_over": "Media attachment {item} was moved.",
+ "upload_form.drag_and_drop.on_drag_start": "Picked up media attachment {item}.",
"upload_form.edit": "Edit",
"upload_form.thumbnail": "Change thumbnail",
"upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
diff --git a/app/javascript/mastodon/models/media_attachment.ts b/app/javascript/mastodon/models/media_attachment.ts
new file mode 100644
index 0000000000..0e5b9ab555
--- /dev/null
+++ b/app/javascript/mastodon/models/media_attachment.ts
@@ -0,0 +1,2 @@
+// Temporary until we type it correctly
+export type MediaAttachment = Immutable.Map;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8cb63e42e6..14de6e6818 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -653,19 +653,39 @@ body > [data-popper-placement] {
}
&__uploads {
- display: flex;
- gap: 8px;
padding: 0 12px;
- flex-wrap: wrap;
- align-self: stretch;
- align-items: flex-start;
- align-content: flex-start;
- justify-content: center;
+ aspect-ratio: 3/2;
+ }
+
+ .media-gallery {
+ gap: 8px;
}
&__upload {
- flex: 1 1 0;
- min-width: calc(50% - 8px);
+ position: relative;
+ cursor: grab;
+
+ &.dragging {
+ opacity: 0;
+ }
+
+ &.overlay {
+ height: 100%;
+ border-radius: 8px;
+ pointer-events: none;
+ }
+
+ &__drag-handle {
+ position: absolute;
+ top: 50%;
+ inset-inline-start: 0;
+ transform: translateY(-50%);
+ color: $white;
+ background: transparent;
+ border: 0;
+ padding: 8px 3px;
+ cursor: grab;
+ }
&__actions {
display: flex;
@@ -686,8 +706,7 @@ body > [data-popper-placement] {
&__thumbnail {
width: 100%;
- height: 144px;
- border-radius: 6px;
+ height: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
@@ -7098,30 +7117,30 @@ a.status-card {
gap: 2px;
&--layout-2 {
- .media-gallery__item:nth-child(1) {
+ & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
- .media-gallery__item:nth-child(2) {
+ & > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
&--layout-3 {
- .media-gallery__item:nth-child(1) {
+ & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
}
- .media-gallery__item:nth-child(2) {
+ & > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
- .media-gallery__item:nth-child(3) {
+ & > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
@@ -7129,26 +7148,26 @@ a.status-card {
}
&--layout-4 {
- .media-gallery__item:nth-child(1) {
+ & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
}
- .media-gallery__item:nth-child(2) {
+ & > .media-gallery__item:nth-child(2) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
- .media-gallery__item:nth-child(3) {
+ & > .media-gallery__item:nth-child(3) {
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
- .media-gallery__item:nth-child(4) {
+ & > .media-gallery__item:nth-child(4) {
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 0;
diff --git a/package.json b/package.json
index 08fec76465..7a6dee1311 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,9 @@
"@babel/preset-react": "^7.22.3",
"@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.22.3",
+ "@dnd-kit/core": "^6.1.0",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@formatjs/intl-pluralrules": "^5.2.2",
"@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1",
diff --git a/yarn.lock b/yarn.lock
index eff88f772e..62a538b42c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2010,6 +2010,55 @@ __metadata:
languageName: node
linkType: hard
+"@dnd-kit/accessibility@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "@dnd-kit/accessibility@npm:3.1.0"
+ dependencies:
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ checksum: 10c0/4f9d24e801d66d4fbb551ec389ed90424dd4c5bbdf527000a618e9abb9833cbd84d9a79e362f470ccbccfbd6d00217a9212c92f3cef66e01c951c7f79625b9d7
+ languageName: node
+ linkType: hard
+
+"@dnd-kit/core@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "@dnd-kit/core@npm:6.1.0"
+ dependencies:
+ "@dnd-kit/accessibility": "npm:^3.1.0"
+ "@dnd-kit/utilities": "npm:^3.2.2"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10c0/c793eb97cb59285ca8937ebcdfcd27cff09d750ae06722e36ca5ed07925e41abc36a38cff98f9f6056f7a07810878d76909826142a2968330e7e22060e6be584
+ languageName: node
+ linkType: hard
+
+"@dnd-kit/sortable@npm:^8.0.0":
+ version: 8.0.0
+ resolution: "@dnd-kit/sortable@npm:8.0.0"
+ dependencies:
+ "@dnd-kit/utilities": "npm:^3.2.2"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ "@dnd-kit/core": ^6.1.0
+ react: ">=16.8.0"
+ checksum: 10c0/a6066c652b892c6a11320c7d8f5c18fdf723e721e8eea37f4ab657dee1ac5e7ca710ac32ce0712a57fe968bc07c13bcea5d5599d90dfdd95619e162befd4d2fb
+ languageName: node
+ linkType: hard
+
+"@dnd-kit/utilities@npm:^3.2.2":
+ version: 3.2.2
+ resolution: "@dnd-kit/utilities@npm:3.2.2"
+ dependencies:
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0
+ languageName: node
+ linkType: hard
+
"@dual-bundle/import-meta-resolve@npm:^4.1.0":
version: 4.1.0
resolution: "@dual-bundle/import-meta-resolve@npm:4.1.0"
@@ -2753,6 +2802,9 @@ __metadata:
"@babel/preset-react": "npm:^7.22.3"
"@babel/preset-typescript": "npm:^7.21.5"
"@babel/runtime": "npm:^7.22.3"
+ "@dnd-kit/core": "npm:^6.1.0"
+ "@dnd-kit/sortable": "npm:^8.0.0"
+ "@dnd-kit/utilities": "npm:^3.2.2"
"@formatjs/cli": "npm:^6.1.1"
"@formatjs/intl-pluralrules": "npm:^5.2.2"
"@gamestdio/websocket": "npm:^0.3.2"
@@ -17205,6 +17257,13 @@ __metadata:
languageName: node
linkType: hard
+"tslib@npm:^2.0.0":
+ version: 2.7.0
+ resolution: "tslib@npm:2.7.0"
+ checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6
+ languageName: node
+ linkType: hard
+
"tslib@npm:^2.4.0, tslib@npm:^2.6.2":
version: 2.6.3
resolution: "tslib@npm:2.6.3"