kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'drop-zones' into 'develop'
Drop zones See merge request soapbox-pub/soapbox!2464environments/review-develop-3zknud/deployments/3256
commit
39d381ae4a
|
@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Posts: improved design of threads.
|
- Posts: improved design of threads.
|
||||||
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
||||||
- UI: added sticky column header.
|
- UI: added sticky column header.
|
||||||
|
- UI: add specific zones the user can drag-and-drop files.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Posts: fixed emojis being cut off in reactions modal.
|
- Posts: fixed emojis being cut off in reactions modal.
|
||||||
|
|
|
@ -55,10 +55,11 @@ interface IModal {
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode
|
||||||
width?: keyof typeof widths
|
width?: keyof typeof widths
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays a modal dialog box. */
|
/** Displays a modal dialog box. */
|
||||||
const Modal: React.FC<IModal> = ({
|
const Modal = React.forwardRef<HTMLDivElement, IModal>(({
|
||||||
cancelAction,
|
cancelAction,
|
||||||
cancelText,
|
cancelText,
|
||||||
children,
|
children,
|
||||||
|
@ -76,7 +77,8 @@ const Modal: React.FC<IModal> = ({
|
||||||
skipFocus = false,
|
skipFocus = false,
|
||||||
title,
|
title,
|
||||||
width = 'xl',
|
width = 'xl',
|
||||||
}) => {
|
className,
|
||||||
|
}, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
@ -87,7 +89,11 @@ const Modal: React.FC<IModal> = ({
|
||||||
}, [skipFocus, buttonRef]);
|
}, [skipFocus, buttonRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='modal' className={clsx('pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}>
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-testid='modal'
|
||||||
|
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}
|
||||||
|
>
|
||||||
<div className='w-full justify-between sm:flex sm:items-start'>
|
<div className='w-full justify-between sm:flex sm:items-start'>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
{title && (
|
{title && (
|
||||||
|
@ -157,6 +163,6 @@ const Modal: React.FC<IModal> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|
|
@ -17,7 +17,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest
|
||||||
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
|
||||||
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
import { Button, HStack, Stack } from 'soapbox/components/ui';
|
||||||
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
|
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 { isMobile } from 'soapbox/is-mobile';
|
||||||
|
|
||||||
import QuotedStatusContainer from '../containers/quoted-status-container';
|
import QuotedStatusContainer from '../containers/quoted-status-container';
|
||||||
|
@ -88,10 +88,12 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
const [composeFocused, setComposeFocused] = useState(false);
|
const [composeFocused, setComposeFocused] = useState(false);
|
||||||
|
|
||||||
const formRef = useRef(null);
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
const spoilerTextRef = useRef<AutosuggestInput>(null);
|
||||||
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
|
||||||
|
|
||||||
|
const { isDraggedOver } = useDraggedFiles(formRef);
|
||||||
|
|
||||||
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
dispatch(changeCompose(id, e.target.value));
|
dispatch(changeCompose(id, e.target.value));
|
||||||
};
|
};
|
||||||
|
@ -236,7 +238,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
</HStack>
|
</HStack>
|
||||||
), [features, id]);
|
), [features, id]);
|
||||||
|
|
||||||
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
|
const condensed = shouldCondense && !isDraggedOver && !composeFocused && isEmpty() && !isUploading;
|
||||||
const disabled = isSubmitting;
|
const disabled = isSubmitting;
|
||||||
const countedText = [spoilerText, countableText(text)].join('');
|
const countedText = [spoilerText, countableText(text)].join('');
|
||||||
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { useEffect } from 'react';
|
import clsx from 'clsx';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
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 { connectGroupStream } from 'soapbox/actions/streaming';
|
||||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||||
import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui';
|
import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
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 { useGroup } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
import Timeline from '../ui/components/timeline';
|
import Timeline from '../ui/components/timeline';
|
||||||
|
@ -19,8 +20,10 @@ interface IGroupTimeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const composer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { groupId } = props.params;
|
const { groupId } = props.params;
|
||||||
|
|
||||||
|
@ -30,6 +33,10 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
const canComposeGroupStatus = !!account && group?.relationship?.member;
|
const canComposeGroupStatus = !!account && group?.relationship?.member;
|
||||||
const groupTimelineVisible = useAppSelector((state) => !!state.compose.get(composeId)?.group_timeline_visible);
|
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) => {
|
const handleLoadMore = (maxId: string) => {
|
||||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||||
};
|
};
|
||||||
|
@ -57,7 +64,15 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
{canComposeGroupStatus && (
|
{canComposeGroupStatus && (
|
||||||
<div className='border-b border-solid border-gray-200 py-6 dark:border-gray-800'>
|
<div className='border-b border-solid border-gray-200 py-6 dark:border-gray-800'>
|
||||||
<HStack alignItems='start' space={4}>
|
<HStack
|
||||||
|
ref={composer}
|
||||||
|
alignItems='start'
|
||||||
|
space={4}
|
||||||
|
className={clsx('relative rounded-xl transition', {
|
||||||
|
'border-2 border-primary-600 border-dashed z-[99] p-4': isDragging,
|
||||||
|
'ring-2 ring-offset-2 ring-primary-600': isDraggedOver,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Link to={`/@${account.acct}`}>
|
<Link to={`/@${account.acct}`}>
|
||||||
<Avatar src={account.avatar} size={46} />
|
<Avatar src={account.avatar} size={46} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -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 { 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 { openModal, closeModal } from 'soapbox/actions/modals';
|
||||||
import { checkComposeContent } from 'soapbox/components/modal-root';
|
import { checkComposeContent } from 'soapbox/components/modal-root';
|
||||||
import { Modal } from 'soapbox/components/ui';
|
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';
|
import ComposeForm from '../../../compose/components/compose-form';
|
||||||
|
|
||||||
|
@ -22,11 +23,17 @@ interface IComposeModal {
|
||||||
const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
|
const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const node = useRef<HTMLDivElement>(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 { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!;
|
||||||
|
|
||||||
|
const { isDragging, isDraggedOver } = useDraggedFiles(node, (files) => {
|
||||||
|
dispatch(uploadCompose(composeId, files, intl));
|
||||||
|
});
|
||||||
|
|
||||||
const onClickClose = () => {
|
const onClickClose = () => {
|
||||||
if (checkComposeContent(compose)) {
|
if (checkComposeContent(compose)) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
|
@ -64,8 +71,13 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
ref={node}
|
||||||
title={renderTitle()}
|
title={renderTitle()}
|
||||||
onClose={onClickClose}
|
onClose={onClickClose}
|
||||||
|
className={clsx({
|
||||||
|
'border-2 border-primary-600 border-dashed !z-[99]': isDragging,
|
||||||
|
'ring-2 ring-offset-2 ring-primary-600': isDraggedOver,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<ComposeForm id='compose-modal' />
|
<ComposeForm id='compose-modal' />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -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<IUploadArea> = ({ 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 (
|
|
||||||
<Motion
|
|
||||||
defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }}
|
|
||||||
style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}
|
|
||||||
>
|
|
||||||
{({ backgroundOpacity, backgroundScale }) => (
|
|
||||||
<div
|
|
||||||
className={clsx({
|
|
||||||
'flex items-center justify-center bg-gray-700/90 h-full w-full absolute left-0 top-0 z-1000 pointer-events-none': true,
|
|
||||||
'visible': active,
|
|
||||||
'invisible': !active,
|
|
||||||
})}
|
|
||||||
style={{ opacity: backgroundOpacity }}
|
|
||||||
>
|
|
||||||
<div className='relative flex h-40 w-80 p-2'>
|
|
||||||
<div className='absolute inset-0' style={{ transform: `scale(${backgroundScale})` }} />
|
|
||||||
|
|
||||||
<Stack space={3} justifyContent='center' alignItems='center'>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/cloud-upload.svg')}
|
|
||||||
className='h-12 w-12 text-white/90'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size='xl' theme='white'>
|
|
||||||
<FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' />
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Motion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UploadArea;
|
|
|
@ -1,16 +1,15 @@
|
||||||
'use strict';
|
'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 { HotKeys } from 'react-hotkeys';
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||||
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
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 { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||||
import { uploadEventBanner } from 'soapbox/actions/events';
|
|
||||||
import { fetchFilters } from 'soapbox/actions/filters';
|
import { fetchFilters } from 'soapbox/actions/filters';
|
||||||
import { fetchMarker } from 'soapbox/actions/markers';
|
import { fetchMarker } from 'soapbox/actions/markers';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
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 ThumbNavigation from 'soapbox/components/thumb-navigation';
|
||||||
import { Layout } from 'soapbox/components/ui';
|
import { Layout } from 'soapbox/components/ui';
|
||||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
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 AdminPage from 'soapbox/pages/admin-page';
|
||||||
import ChatsPage from 'soapbox/pages/chats-page';
|
import ChatsPage from 'soapbox/pages/chats-page';
|
||||||
import DefaultPage from 'soapbox/pages/default-page';
|
import DefaultPage from 'soapbox/pages/default-page';
|
||||||
|
@ -101,7 +100,6 @@ import {
|
||||||
FollowRecommendations,
|
FollowRecommendations,
|
||||||
Directory,
|
Directory,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
UploadArea,
|
|
||||||
ProfileHoverCard,
|
ProfileHoverCard,
|
||||||
StatusHoverCard,
|
StatusHoverCard,
|
||||||
Share,
|
Share,
|
||||||
|
@ -387,16 +385,12 @@ interface IUI {
|
||||||
}
|
}
|
||||||
|
|
||||||
const UI: React.FC<IUI> = ({ children }) => {
|
const UI: React.FC<IUI> = ({ children }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { data: pendingPolicy } = usePendingPolicy();
|
const { data: pendingPolicy } = usePendingPolicy();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
const statContext = useStatContext();
|
const statContext = useStatContext();
|
||||||
|
|
||||||
const [draggingOver, setDraggingOver] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const dragTargets = useRef<EventTarget[]>([]);
|
|
||||||
const disconnect = useRef<any>(null);
|
const disconnect = useRef<any>(null);
|
||||||
const node = useRef<HTMLDivElement | null>(null);
|
const node = useRef<HTMLDivElement | null>(null);
|
||||||
const hotkeys = useRef<HTMLDivElement | null>(null);
|
const hotkeys = useRef<HTMLDivElement | null>(null);
|
||||||
|
@ -411,74 +405,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
const streamingUrl = instance.urls.get('streaming_api');
|
const streamingUrl = instance.urls.get('streaming_api');
|
||||||
const standalone = useAppSelector(isStandalone);
|
const standalone = useAppSelector(isStandalone);
|
||||||
|
|
||||||
const handleDragEnter = (e: DragEvent) => {
|
const { isDragging } = useDraggedFiles(node);
|
||||||
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 handleServiceWorkerPostMessage = ({ data }: MessageEvent) => {
|
const handleServiceWorkerPostMessage = ({ data }: MessageEvent) => {
|
||||||
if (data.type === 'navigate') {
|
if (data.type === 'navigate') {
|
||||||
|
@ -501,6 +428,11 @@ const UI: React.FC<IUI> = ({ 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 */
|
/** Load initial data when a user is logged in */
|
||||||
const loadAccountData = () => {
|
const loadAccountData = () => {
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
|
@ -535,11 +467,6 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage);
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage);
|
||||||
}
|
}
|
||||||
|
@ -548,12 +475,21 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
|
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 () => {
|
return () => {
|
||||||
document.removeEventListener('dragenter', handleDragEnter);
|
document.removeEventListener('dragenter', handleDragEnter);
|
||||||
|
document.removeEventListener('dragleave', handleDragLeave);
|
||||||
document.removeEventListener('dragover', handleDragOver);
|
document.removeEventListener('dragover', handleDragOver);
|
||||||
document.removeEventListener('drop', handleDrop);
|
document.removeEventListener('drop', handleDrop);
|
||||||
document.removeEventListener('dragleave', handleDragLeave);
|
|
||||||
disconnectStreaming();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -697,6 +633,12 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
|
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
|
||||||
<div ref={node} style={style}>
|
<div ref={node} style={style}>
|
||||||
|
<div
|
||||||
|
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
|
||||||
|
'backdrop-blur': isDragging,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
<BackgroundShapes />
|
<BackgroundShapes />
|
||||||
|
|
||||||
<div className='z-10 flex flex-col'>
|
<div className='z-10 flex flex-col'>
|
||||||
|
@ -718,10 +660,6 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BundleContainer fetchComponent={UploadArea}>
|
|
||||||
{Component => <Component active={draggingOver} onClose={closeUploadModal} />}
|
|
||||||
</BundleContainer>
|
|
||||||
|
|
||||||
{me && (
|
{me && (
|
||||||
<BundleContainer fetchComponent={SidebarMenu}>
|
<BundleContainer fetchComponent={SidebarMenu}>
|
||||||
{Component => <Component />}
|
{Component => <Component />}
|
||||||
|
|
|
@ -374,10 +374,6 @@ export function SidebarMenu() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar-menu');
|
return import(/* webpackChunkName: "features/ui" */'../../../components/sidebar-menu');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadArea() {
|
|
||||||
return import(/* webpackChunkName: "features/compose" */'../components/upload-area');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModalContainer() {
|
export function ModalContainer() {
|
||||||
return import(/* webpackChunkName: "features/ui" */'../containers/modal-container');
|
return import(/* webpackChunkName: "features/ui" */'../containers/modal-container');
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ export { useBackend } from './useBackend';
|
||||||
export { useClickOutside } from './useClickOutside';
|
export { useClickOutside } from './useClickOutside';
|
||||||
export { useCompose } from './useCompose';
|
export { useCompose } from './useCompose';
|
||||||
export { useDebounce } from './useDebounce';
|
export { useDebounce } from './useDebounce';
|
||||||
|
export { useDraggedFiles } from './useDraggedFiles';
|
||||||
export { useGetState } from './useGetState';
|
export { useGetState } from './useGetState';
|
||||||
export { useGroupsPath } from './useGroupsPath';
|
export { useGroupsPath } from './useGroupsPath';
|
||||||
export { useDimensions } from './useDimensions';
|
export { useDimensions } from './useDimensions';
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/** Controls the state of files being dragged over a node. */
|
||||||
|
function useDraggedFiles<R extends HTMLElement>(node: React.RefObject<R>, 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 };
|
|
@ -1546,7 +1546,6 @@
|
||||||
"trendsPanel.viewAll": "View all",
|
"trendsPanel.viewAll": "View all",
|
||||||
"unauthorized_modal.text": "You need to be logged in to do that.",
|
"unauthorized_modal.text": "You need to be logged in to do that.",
|
||||||
"unauthorized_modal.title": "Sign up for {site_title}",
|
"unauthorized_modal.title": "Sign up for {site_title}",
|
||||||
"upload_area.title": "Drag & drop to upload",
|
|
||||||
"upload_button.label": "Add media attachment",
|
"upload_button.label": "Add media attachment",
|
||||||
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
|
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
|
||||||
"upload_error.limit": "File upload limit exceeded.",
|
"upload_error.limit": "File upload limit exceeded.",
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { uploadCompose } from 'soapbox/actions/compose';
|
||||||
import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel';
|
import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel';
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
import {
|
import {
|
||||||
|
@ -14,7 +17,7 @@ import {
|
||||||
CtaBanner,
|
CtaBanner,
|
||||||
AnnouncementsPanel,
|
AnnouncementsPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} 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 { Avatar, Card, CardBody, HStack, Layout } from '../components/ui';
|
||||||
import ComposeForm from '../features/compose/components/compose-form';
|
import ComposeForm from '../features/compose/components/compose-form';
|
||||||
|
@ -25,17 +28,25 @@ interface IHomePage {
|
||||||
}
|
}
|
||||||
|
|
||||||
const HomePage: React.FC<IHomePage> = ({ children }) => {
|
const HomePage: React.FC<IHomePage> = ({ children }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const me = useAppSelector(state => state.me);
|
const me = useAppSelector(state => state.me);
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
|
const composeId = 'home';
|
||||||
const composeBlock = useRef<HTMLDivElement>(null);
|
const composeBlock = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const hasPatron = soapboxConfig.extensions.getIn(['patron', 'enabled']) === true;
|
const hasPatron = soapboxConfig.extensions.getIn(['patron', 'enabled']) === true;
|
||||||
const hasCrypto = typeof soapboxConfig.cryptoAddresses.getIn([0, 'ticker']) === 'string';
|
const hasCrypto = typeof soapboxConfig.cryptoAddresses.getIn([0, 'ticker']) === 'string';
|
||||||
const cryptoLimit = soapboxConfig.cryptoDonatePanel.get('limit', 0);
|
const cryptoLimit = soapboxConfig.cryptoDonatePanel.get('limit', 0);
|
||||||
|
|
||||||
|
const { isDragging, isDraggedOver } = useDraggedFiles(composeBlock, (files) => {
|
||||||
|
dispatch(uploadCompose(composeId, files, intl));
|
||||||
|
});
|
||||||
|
|
||||||
const acct = account ? account.acct : '';
|
const acct = account ? account.acct : '';
|
||||||
const avatar = account ? account.avatar : '';
|
const avatar = account ? account.avatar : '';
|
||||||
|
|
||||||
|
@ -43,7 +54,14 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
|
||||||
<>
|
<>
|
||||||
<Layout.Main className='space-y-3 pt-3 dark:divide-gray-800 sm:pt-0'>
|
<Layout.Main className='space-y-3 pt-3 dark:divide-gray-800 sm:pt-0'>
|
||||||
{me && (
|
{me && (
|
||||||
<Card className='relative z-[1]' variant='rounded' ref={composeBlock}>
|
<Card
|
||||||
|
className={clsx('relative z-[1] transition', {
|
||||||
|
'border-2 border-primary-600 border-dashed z-[99]': isDragging,
|
||||||
|
'ring-2 ring-offset-2 ring-primary-600': isDraggedOver,
|
||||||
|
})}
|
||||||
|
variant='rounded'
|
||||||
|
ref={composeBlock}
|
||||||
|
>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<HStack alignItems='start' space={4}>
|
<HStack alignItems='start' space={4}>
|
||||||
<Link to={`/@${acct}`}>
|
<Link to={`/@${acct}`}>
|
||||||
|
@ -52,7 +70,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
|
||||||
|
|
||||||
<div className='w-full translate-y-0.5'>
|
<div className='w-full translate-y-0.5'>
|
||||||
<ComposeForm
|
<ComposeForm
|
||||||
id='home'
|
id={composeId}
|
||||||
shouldCondense
|
shouldCondense
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
clickableAreaRef={composeBlock}
|
clickableAreaRef={composeBlock}
|
||||||
|
|
Ładowanie…
Reference in New Issue