diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bc03ee8..b27bae103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Posts: improved design of threads. - UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. - UI: added sticky column header. +- UI: add specific zones the user can drag-and-drop files. ### Fixed - Posts: fixed emojis being cut off in reactions modal. diff --git a/app/soapbox/components/ui/modal/modal.tsx b/app/soapbox/components/ui/modal/modal.tsx index 69daac0b8..98b001f4c 100644 --- a/app/soapbox/components/ui/modal/modal.tsx +++ b/app/soapbox/components/ui/modal/modal.tsx @@ -55,10 +55,11 @@ interface IModal { title?: React.ReactNode width?: keyof typeof widths children?: React.ReactNode + className?: string } /** Displays a modal dialog box. */ -const Modal: React.FC = ({ +const Modal = React.forwardRef(({ cancelAction, cancelText, children, @@ -76,7 +77,8 @@ const Modal: React.FC = ({ skipFocus = false, title, width = 'xl', -}) => { + className, +}, ref) => { const intl = useIntl(); const buttonRef = React.useRef(null); @@ -87,7 +89,11 @@ const Modal: React.FC = ({ }, [skipFocus, buttonRef]); return ( -
+
{title && ( @@ -157,6 +163,6 @@ const Modal: React.FC = ({ )}
); -}; +}); export default Modal; diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 5940dd7dd..92364b7b0 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -17,7 +17,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea'; import { Button, HStack, Stack } from 'soapbox/components/ui'; import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container'; -import { useAppDispatch, useAppSelector, useCompose, useFeatures, useInstance, usePrevious } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles, useFeatures, useInstance, usePrevious } from 'soapbox/hooks'; import { isMobile } from 'soapbox/is-mobile'; import QuotedStatusContainer from '../containers/quoted-status-container'; @@ -88,10 +88,12 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab const [composeFocused, setComposeFocused] = useState(false); - const formRef = useRef(null); + const formRef = useRef(null); const spoilerTextRef = useRef(null); const autosuggestTextareaRef = useRef(null); + const { isDraggedOver } = useDraggedFiles(formRef); + const handleChange: React.ChangeEventHandler = (e) => { dispatch(changeCompose(id, e.target.value)); }; @@ -236,7 +238,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab ), [features, id]); - const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading; + const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading; const disabled = isSubmitting; const countedText = [spoilerText, countableText(text)].join(''); const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia); diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index 53f570280..d189fdb68 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -1,13 +1,14 @@ -import React, { useEffect } from 'react'; -import { FormattedMessage } from 'react-intl'; +import clsx from 'clsx'; +import React, { useEffect, useRef } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; -import { groupCompose, setGroupTimelineVisible } from 'soapbox/actions/compose'; +import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { connectGroupStream } from 'soapbox/actions/streaming'; import { expandGroupTimeline } from 'soapbox/actions/timelines'; import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui'; import ComposeForm from 'soapbox/features/compose/components/compose-form'; -import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks'; import { useGroup } from 'soapbox/hooks/api'; import Timeline from '../ui/components/timeline'; @@ -19,8 +20,10 @@ interface IGroupTimeline { } const GroupTimeline: React.FC = (props) => { + const intl = useIntl(); const account = useOwnAccount(); const dispatch = useAppDispatch(); + const composer = useRef(null); const { groupId } = props.params; @@ -30,6 +33,10 @@ const GroupTimeline: React.FC = (props) => { const canComposeGroupStatus = !!account && group?.relationship?.member; const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible); + const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => { + dispatch(uploadCompose(composeId, files, intl)); + }); + const handleLoadMore = (maxId: string) => { dispatch(expandGroupTimeline(groupId, { maxId })); }; @@ -57,7 +64,15 @@ const GroupTimeline: React.FC = (props) => { {canComposeGroupStatus && (
- + diff --git a/app/soapbox/features/ui/components/modals/compose-modal.tsx b/app/soapbox/features/ui/components/modals/compose-modal.tsx index b007ae1ba..72a06fb7c 100644 --- a/app/soapbox/features/ui/components/modals/compose-modal.tsx +++ b/app/soapbox/features/ui/components/modals/compose-modal.tsx @@ -1,11 +1,12 @@ -import React from 'react'; +import clsx from 'clsx'; +import React, { useRef } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { cancelReplyCompose } from 'soapbox/actions/compose'; +import { cancelReplyCompose, uploadCompose } from 'soapbox/actions/compose'; import { openModal, closeModal } from 'soapbox/actions/modals'; import { checkComposeContent } from 'soapbox/components/modal-root'; import { Modal } from 'soapbox/components/ui'; -import { useAppDispatch, useCompose } from 'soapbox/hooks'; +import { useAppDispatch, useCompose, useDraggedFiles } from 'soapbox/hooks'; import ComposeForm from '../../../compose/components/compose-form'; @@ -22,11 +23,17 @@ interface IComposeModal { const ComposeModal: React.FC = ({ onClose }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const node = useRef(null); - const compose = useCompose('compose-modal'); + const composeId = 'compose-modal'; + const compose = useCompose(composeId); const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!; + const { isDragging, isDraggedOver } = useDraggedFiles(node, (files) => { + dispatch(uploadCompose(composeId, files, intl)); + }); + const onClickClose = () => { if (checkComposeContent(compose)) { dispatch(openModal('CONFIRM', { @@ -64,8 +71,13 @@ const ComposeModal: React.FC = ({ onClose }) => { return ( diff --git a/app/soapbox/features/ui/components/upload-area.tsx b/app/soapbox/features/ui/components/upload-area.tsx deleted file mode 100644 index 42f363749..000000000 --- a/app/soapbox/features/ui/components/upload-area.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { spring } from 'react-motion'; - -import { Icon, Stack, Text } from 'soapbox/components/ui'; - -import Motion from '../util/optional-motion'; - -interface IUploadArea { - /** Whether the upload area is active. */ - active: boolean - /** Callback when the upload area is closed. */ - onClose: () => void -} - -/** Component to display when a file is dragged over the UI. */ -const UploadArea: React.FC = ({ active, onClose }) => { - const handleKeyUp = (e: KeyboardEvent) => { - const keyCode = e.keyCode; - - if (active) { - switch (keyCode) { - case 27: - e.preventDefault(); - e.stopPropagation(); - onClose(); - break; - } - } - }; - - React.useEffect(() => { - window.addEventListener('keyup', handleKeyUp, false); - - return () => window.removeEventListener('keyup', handleKeyUp); - }, []); - - return ( - - {({ backgroundOpacity, backgroundScale }) => ( -
-
-
- - - - - - - - -
-
- )} - - ); -}; - -export default UploadArea; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index b41cd6746..298e3314c 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -1,16 +1,15 @@ 'use strict'; -import React, { useState, useEffect, useRef } from 'react'; +import clsx from 'clsx'; +import React, { useEffect, useRef } from 'react'; import { HotKeys } from 'react-hotkeys'; -import { useIntl } from 'react-intl'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchAnnouncements } from 'soapbox/actions/announcements'; -import { uploadCompose, resetCompose } from 'soapbox/actions/compose'; +import { resetCompose } from 'soapbox/actions/compose'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; -import { uploadEventBanner } from 'soapbox/actions/events'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; import { openModal } from 'soapbox/actions/modals'; @@ -26,7 +25,7 @@ import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import ThumbNavigation from 'soapbox/components/thumb-navigation'; import { Layout } from 'soapbox/components/ui'; import { useStatContext } from 'soapbox/contexts/stat-context'; -import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance, useDraggedFiles } from 'soapbox/hooks'; import AdminPage from 'soapbox/pages/admin-page'; import ChatsPage from 'soapbox/pages/chats-page'; import DefaultPage from 'soapbox/pages/default-page'; @@ -101,7 +100,6 @@ import { FollowRecommendations, Directory, SidebarMenu, - UploadArea, ProfileHoverCard, StatusHoverCard, Share, @@ -387,16 +385,12 @@ interface IUI { } const UI: React.FC = ({ children }) => { - const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); const { data: pendingPolicy } = usePendingPolicy(); const instance = useInstance(); const statContext = useStatContext(); - const [draggingOver, setDraggingOver] = useState(false); - - const dragTargets = useRef([]); const disconnect = useRef(null); const node = useRef(null); const hotkeys = useRef(null); @@ -411,74 +405,7 @@ const UI: React.FC = ({ children }) => { const streamingUrl = instance.urls.get('streaming_api'); const standalone = useAppSelector(isStandalone); - const handleDragEnter = (e: DragEvent) => { - e.preventDefault(); - - if (e.target && !dragTargets.current.includes(e.target)) { - dragTargets.current.push(e.target); - } - - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - setDraggingOver(true); - } - }; - - const handleDragOver = (e: DragEvent) => { - if (dataTransferIsText(e.dataTransfer)) return false; - e.preventDefault(); - e.stopPropagation(); - - try { - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy'; - } - } catch (err) { - // Do nothing - } - - return false; - }; - - const handleDrop = (e: DragEvent) => { - if (!me) return; - - if (dataTransferIsText(e.dataTransfer)) return; - e.preventDefault(); - - setDraggingOver(false); - dragTargets.current = []; - - dispatch((_, getState) => { - if (e.dataTransfer && e.dataTransfer.files.length >= 1) { - const modals = getState().modals; - const isModalOpen = modals.last()?.modalType === 'COMPOSE'; - const isEventsModalOpen = modals.last()?.modalType === 'COMPOSE_EVENT'; - if (isEventsModalOpen) dispatch(uploadEventBanner(e.dataTransfer.files[0], intl)); - else dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl)); - } - }); - }; - - const handleDragLeave = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - dragTargets.current = dragTargets.current.filter(el => el !== e.target && node.current?.contains(el as Node)); - - if (dragTargets.current.length > 0) { - return; - } - - setDraggingOver(false); - }; - - const dataTransferIsText = (dataTransfer: DataTransfer | null) => { - return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); - }; - - const closeUploadModal = () => { - setDraggingOver(false); - }; + const { isDragging } = useDraggedFiles(node); const handleServiceWorkerPostMessage = ({ data }: MessageEvent) => { if (data.type === 'navigate') { @@ -501,6 +428,11 @@ const UI: React.FC = ({ children }) => { } }; + const handleDragEnter = (e: DragEvent) => e.preventDefault(); + const handleDragLeave = (e: DragEvent) => e.preventDefault(); + const handleDragOver = (e: DragEvent) => e.preventDefault(); + const handleDrop = (e: DragEvent) => e.preventDefault(); + /** Load initial data when a user is logged in */ const loadAccountData = () => { if (!account) return; @@ -535,11 +467,6 @@ const UI: React.FC = ({ children }) => { }; useEffect(() => { - document.addEventListener('dragenter', handleDragEnter, false); - document.addEventListener('dragover', handleDragOver, false); - document.addEventListener('drop', handleDrop, false); - document.addEventListener('dragleave', handleDragLeave, false); - if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage); } @@ -548,12 +475,21 @@ const UI: React.FC = ({ children }) => { window.setTimeout(() => Notification.requestPermission(), 120 * 1000); } + return () => { + disconnectStreaming(); + }; + }, []); + + useEffect(() => { + document.addEventListener('dragenter', handleDragEnter); + document.addEventListener('dragleave', handleDragLeave); + document.addEventListener('dragover', handleDragOver); + document.addEventListener('drop', handleDrop); return () => { document.removeEventListener('dragenter', handleDragEnter); + document.removeEventListener('dragleave', handleDragLeave); document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); - document.removeEventListener('dragleave', handleDragLeave); - disconnectStreaming(); }; }, []); @@ -697,6 +633,12 @@ const UI: React.FC = ({ children }) => { return (
+
+
@@ -718,10 +660,6 @@ const UI: React.FC = ({ children }) => {
)} - - {Component => } - - {me && ( {Component => } diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index e8187db46..0b50f0194 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -374,10 +374,6 @@ export function SidebarMenu() { return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar-menu'); } -export function UploadArea() { - return import(/* webpackChunkName: "features/compose" */'../components/upload-area'); -} - export function ModalContainer() { return import(/* webpackChunkName: "features/ui" */'../containers/modal-container'); } diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 6f52e0c8f..0bd63eb21 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -6,6 +6,7 @@ export { useBackend } from './useBackend'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; +export { useDraggedFiles } from './useDraggedFiles'; export { useGetState } from './useGetState'; export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; diff --git a/app/soapbox/hooks/useDraggedFiles.ts b/app/soapbox/hooks/useDraggedFiles.ts new file mode 100644 index 000000000..8f519d16e --- /dev/null +++ b/app/soapbox/hooks/useDraggedFiles.ts @@ -0,0 +1,96 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +/** Controls the state of files being dragged over a node. */ +function useDraggedFiles(node: React.RefObject, onDrop?: (files: FileList) => void) { + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(false); + + const handleDocumentDragEnter = useCallback((e: DragEvent) => { + if (isDraggingFiles(e)) { + setIsDragging(true); + } + }, [setIsDragging]); + + const handleDocumentDragLeave = useCallback((e: DragEvent) => { + if (isDraggedOffscreen(e)) { + setIsDragging(false); + } + }, [setIsDragging]); + + const handleDocumentDrop = useCallback((e: DragEvent) => { + setIsDragging(false); + setIsDraggedOver(false); + }, [setIsDragging]); + + const handleDragEnter = useCallback((e: DragEvent) => { + if (isDraggingFiles(e)) { + setIsDraggedOver(true); + } + }, [setIsDraggedOver]); + + const handleDragLeave = useCallback((e: DragEvent) => { + if (!node.current || isDraggedOutOfNode(e, node.current)) { + setIsDraggedOver(false); + } + }, [setIsDraggedOver]); + + const handleDrop = useCallback((e: DragEvent) => { + if (isDraggingFiles(e) && onDrop) { + onDrop(e.dataTransfer.files); + } + setIsDragging(false); + setIsDraggedOver(false); + e.preventDefault(); + }, [onDrop]); + + useEffect(() => { + document.addEventListener('dragenter', handleDocumentDragEnter); + document.addEventListener('dragleave', handleDocumentDragLeave); + document.addEventListener('drop', handleDocumentDrop); + return () => { + document.removeEventListener('dragenter', handleDocumentDragEnter); + document.removeEventListener('dragleave', handleDocumentDragLeave); + document.removeEventListener('drop', handleDocumentDrop); + }; + }, []); + + useEffect(() => { + node.current?.addEventListener('dragenter', handleDragEnter); + node.current?.addEventListener('dragleave', handleDragLeave); + node.current?.addEventListener('drop', handleDrop); + return () => { + node.current?.removeEventListener('dragenter', handleDragEnter); + node.current?.removeEventListener('dragleave', handleDragLeave); + node.current?.removeEventListener('drop', handleDrop); + }; + }, [node.current]); + + return { + /** Whether the document is being dragged over. */ + isDragging, + /** Whether the node is being dragged over. */ + isDraggedOver, + }; +} + +/** Ensure only files are being dragged, and not eg highlighted text. */ +function isDraggingFiles(e: DragEvent): e is DragEvent & { dataTransfer: DataTransfer } { + if (e.dataTransfer) { + const { types } = e.dataTransfer; + return types.length === 1 && types[0] === 'Files'; + } else { + return false; + } +} + +/** Check whether the cursor is in the screen. Mostly useful for dragleave events. */ +function isDraggedOffscreen(e: DragEvent): boolean { + return e.screenX === 0 && e.screenY === 0; +} + +/** Check whether the cursor is dragged out of the node. */ +function isDraggedOutOfNode(e: DragEvent, node: Node): boolean { + return !node.contains(document.elementFromPoint(e.clientX, e.clientY)); +} + +export { useDraggedFiles }; \ No newline at end of file diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 8a8e23db7..3f6c24526 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -1546,7 +1546,6 @@ "trendsPanel.viewAll": "View all", "unauthorized_modal.text": "You need to be logged in to do that.", "unauthorized_modal.title": "Sign up for {site_title}", - "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media attachment", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "File upload limit exceeded.", diff --git a/app/soapbox/pages/home-page.tsx b/app/soapbox/pages/home-page.tsx index 2ba7cac0a..1df0572a6 100644 --- a/app/soapbox/pages/home-page.tsx +++ b/app/soapbox/pages/home-page.tsx @@ -1,6 +1,9 @@ +import clsx from 'clsx'; import React, { useRef } from 'react'; +import { useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; +import { uploadCompose } from 'soapbox/actions/compose'; import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel'; import LinkFooter from 'soapbox/features/ui/components/link-footer'; import { @@ -14,7 +17,7 @@ import { CtaBanner, AnnouncementsPanel, } from 'soapbox/features/ui/util/async-components'; -import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; +import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig, useDraggedFiles, useAppDispatch } from 'soapbox/hooks'; import { Avatar, Card, CardBody, HStack, Layout } from '../components/ui'; import ComposeForm from '../features/compose/components/compose-form'; @@ -25,17 +28,25 @@ interface IHomePage { } const HomePage: React.FC = ({ children }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const me = useAppSelector(state => state.me); const account = useOwnAccount(); const features = useFeatures(); const soapboxConfig = useSoapboxConfig(); + const composeId = 'home'; const composeBlock = useRef(null); const hasPatron = soapboxConfig.extensions.getIn(['patron', 'enabled']) === true; const hasCrypto = typeof soapboxConfig.cryptoAddresses.getIn([0, 'ticker']) === 'string'; const cryptoLimit = soapboxConfig.cryptoDonatePanel.get('limit', 0); + const { isDragging, isDraggedOver } = useDraggedFiles(composeBlock, (files) => { + dispatch(uploadCompose(composeId, files, intl)); + }); + const acct = account ? account.acct : ''; const avatar = account ? account.avatar : ''; @@ -43,7 +54,14 @@ const HomePage: React.FC = ({ children }) => { <> {me && ( - + @@ -52,7 +70,7 @@ const HomePage: React.FC = ({ children }) => {