diff --git a/CHANGELOG.md b/CHANGELOG.md index 840a4f839..a92dff733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Expose reflector status for publishes ([#4148](https://github.com/lbryio/lbry-desktop/pull/4148)) - More tooltip help texts _community pr!_ ([#4185](https://github.com/lbryio/lbry-desktop/pull/4185)) - Add footer on web ([#4159](https://github.com/lbryio/lbry-desktop/pull/4159)) +- Support drag-and-drop file publishing _community pr!_ ([#4170](https://github.com/lbryio/lbry-desktop/pull/4170)) ### Changed diff --git a/static/app-strings.json b/static/app-strings.json index 1ced8769f..9d0121e3a 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1218,4 +1218,4 @@ "Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.": "Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.", "blocked channels": "blocked channels", "%count% %channels%. ": "%count% %channels%. " -} \ No newline at end of file +} diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index a1d5fef0c..5c834d2e8 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -16,6 +16,7 @@ import usePrevious from 'effects/use-previous'; import Nag from 'component/common/nag'; import { rewards as REWARDS } from 'lbryinc'; import usePersistedState from 'effects/use-persisted-state'; +import FileDrop from 'component/fileDrop'; // @if TARGET='web' import OpenInAppLink from 'lbrytv/component/openInAppLink'; import YoutubeWelcome from 'lbrytv/component/youtubeReferralWelcome'; @@ -284,6 +285,7 @@ function App(props: Props) { + {isEnhancedLayout && } diff --git a/ui/component/common/file-list.jsx b/ui/component/common/file-list.jsx new file mode 100644 index 000000000..733eca3ad --- /dev/null +++ b/ui/component/common/file-list.jsx @@ -0,0 +1,74 @@ +// @flow +import React from 'react'; +import { useRadioState, Radio, RadioGroup } from 'reakit/Radio'; + +type Props = { + files: Array, + onChange: (WebFile | void) => void, +}; + +type RadioProps = { + id: string, + label: string, +}; + +// Same as FormField type="radio" but it works with reakit: +const ForwardedRadio = React.forwardRef((props: RadioProps, ref) => ( + + + + +)); + +function FileList(props: Props) { + const { files, onChange } = props; + const radio = useRadioState(); + + const getFile = (value?: string) => { + if (files && files.length) { + return files.find((file: WebFile) => file.name === value); + } + }; + + React.useEffect(() => { + if (radio.stops.length) { + if (!radio.currentId) { + radio.first(); + } else { + const first = radio.stops[0].ref.current; + // First auto-selection + if (first && first.id === radio.currentId && !radio.state) { + const file = getFile(first.value); + // Update state and select new file + onChange(file); + radio.setState(first.value); + } + + if (radio.state) { + // Find selected element + const stop = radio.stops.find(item => item.id === radio.currentId); + const element = stop && stop.ref.current; + // Only update state if new item is selected + if (element && element.value !== radio.state) { + const file = getFile(element.value); + // Sselect new file and update state + onChange(file); + radio.setState(element.value); + } + } + } + } + }, [radio, onChange]); + + return ( +
+ + {files.map(({ name }) => { + return ; + })} + +
+ ); +} + +export default FileList; diff --git a/ui/component/common/icon-custom.jsx b/ui/component/common/icon-custom.jsx index 59fc4cac0..0423b2aff 100644 --- a/ui/component/common/icon-custom.jsx +++ b/ui/component/common/icon-custom.jsx @@ -623,4 +623,10 @@ export const icons = { ), + [ICONS.COMPLETED]: buildIcon( + + + + + ), }; diff --git a/ui/component/fileDrop/index.js b/ui/component/fileDrop/index.js new file mode 100644 index 000000000..9a75644b8 --- /dev/null +++ b/ui/component/fileDrop/index.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; + +import { doUpdatePublishForm, makeSelectPublishFormValue } from 'lbry-redux'; + +import { selectModal } from 'redux/selectors/app'; +import { doOpenModal } from 'redux/actions/app'; + +import FileDrop from './view'; + +const select = state => ({ + modal: selectModal(state), + filePath: makeSelectPublishFormValue('filePath')(state), +}); + +const perform = dispatch => ({ + openModal: (modal, props) => dispatch(doOpenModal(modal, props)), + updatePublishForm: value => dispatch(doUpdatePublishForm(value)), +}); + +export default connect(select, perform)(FileDrop); diff --git a/ui/component/fileDrop/view.jsx b/ui/component/fileDrop/view.jsx new file mode 100644 index 000000000..6e031f879 --- /dev/null +++ b/ui/component/fileDrop/view.jsx @@ -0,0 +1,162 @@ +// @flow +import React from 'react'; +import * as ICONS from 'constants/icons'; +import * as PAGES from 'constants/pages'; +import * as MODALS from 'constants/modal_types'; +import classnames from 'classnames'; +import useDragDrop from 'effects/use-drag-drop'; +import { getTree } from 'util/web-file-system'; +import { withRouter } from 'react-router'; +import Icon from 'component/common/icon'; + +type Props = { + modal: { id: string, modalProps: {} }, + filePath: string | WebFile, + clearPublish: () => void, + updatePublishForm: ({}) => void, + openModal: (id: string, { files: Array }) => void, + // React router + history: { + entities: {}[], + goBack: () => void, + goForward: () => void, + index: number, + length: number, + location: { pathname: string }, + push: string => void, + }, +}; + +const HIDE_TIME_OUT = 600; +const TARGET_TIME_OUT = 300; +const NAVIGATE_TIME_OUT = 400; +const PUBLISH_URL = `/$/${PAGES.PUBLISH}`; + +function FileDrop(props: Props) { + const { modal, history, openModal, updatePublishForm } = props; + const { drag, dropData } = useDragDrop(); + const [files, setFiles] = React.useState([]); + const [error, setError] = React.useState(false); + const [target, setTarget] = React.useState(false); + const hideTimer = React.useRef(null); + const targetTimer = React.useRef(null); + const navigationTimer = React.useRef(null); + + const navigateToPublish = React.useCallback(() => { + // Navigate only if location is not publish area: + // - Prevent spam in history + if (history.location.pathname !== PUBLISH_URL) { + history.push(PUBLISH_URL); + } + }, [history]); + + // Delay hide and navigation for a smooth transition + const hideDropArea = () => { + hideTimer.current = setTimeout(() => { + setFiles([]); + // Navigate to publish area + navigationTimer.current = setTimeout(() => { + navigateToPublish(); + }, NAVIGATE_TIME_OUT); + }, HIDE_TIME_OUT); + }; + + const handleFileSelected = selectedFile => { + updatePublishForm({ filePath: selectedFile }); + hideDropArea(); + }; + + const getFileIcon = type => { + // Not all files have a type + if (!type) { + return ICONS.FILE; + } + // Detect common types + const contentType = type.split('/')[0]; + if (contentType === 'text') { + return ICONS.TEXT; + } else if (contentType === 'image') { + return ICONS.IMAGE; + } else if (contentType === 'video') { + return ICONS.VIDEO; + } else if (contentType === 'audio') { + return ICONS.AUDIO; + } + // Binary file + return ICONS.FILE; + }; + + // Clear timers + React.useEffect(() => { + return () => { + // Clear hide timer + if (hideTimer.current) { + clearTimeout(hideTimer.current); + } + // Clear target timer + if (targetTimer.current) { + clearTimeout(targetTimer.current); + } + // Clear navigation timer + if (navigationTimer.current) { + clearTimeout(navigationTimer.current); + } + }; + }, []); + + React.useEffect(() => { + // Clear selected file after modal closed + if ((target && !files) || !files.length) { + // Small delay for a better transition + targetTimer.current = setTimeout(() => { + setTarget(null); + }, TARGET_TIME_OUT); + } + }, [files, target]); + + React.useEffect(() => { + // Handle drop... + if (dropData && !files.length && (!modal || modal.id !== MODALS.FILE_SELECTION)) { + getTree(dropData) + .then(entries => { + if (entries && entries.length) { + setFiles(entries); + } + }) + .catch(error => { + setError(error || true); + }); + } + }, [dropData, files, modal]); + + React.useEffect(() => { + // Files or directory dropped: + if (!drag && files.length) { + // Handle multiple files selection + if (files.length > 1) { + openModal(MODALS.FILE_SELECTION, { files: files }); + setFiles([]); + } else if (files.length === 1) { + // Handle single file selection + setTarget(files[0]); + handleFileSelected(files[0]); + } + } + }, [drag, files, error, openModal]); + + // Show icon based on file type + const icon = target ? getFileIcon(target.type) : ICONS.PUBLISH; + // Show drop area when files are dragged over or processing dropped file + const show = files.length === 1 || (!target && drag && (!modal || modal.id !== MODALS.FILE_SELECTION)); + + return ( +
+
+ +

{target ? target.name : __(`Drop here to publish!`)}

+
+
+ ); +} + +export default withRouter(FileDrop); diff --git a/ui/component/publishFile/view.jsx b/ui/component/publishFile/view.jsx index 2ad301134..290ff8a19 100644 --- a/ui/component/publishFile/view.jsx +++ b/ui/component/publishFile/view.jsx @@ -47,6 +47,8 @@ function PublishFile(props: Props) { const { available } = ffmpegStatus; const [oversized, setOversized] = useState(false); + const [currentFile, setCurrentFile] = useState(null); + const RECOMMENDED_BITRATE = 6000000; const TV_PUBLISH_SIZE_LIMIT: number = 1073741824; const UPLOAD_SIZE_MESSAGE = 'Lbry.tv uploads are limited to 1 GB. Download the app for unrestricted publishing.'; @@ -60,19 +62,16 @@ function PublishFile(props: Props) { // clear warnings useEffect(() => { if (!filePath || filePath === '') { - updateOptimizeState(0, 0, false); + setCurrentFile(''); setOversized(false); + updateOptimizeState(0, 0, false); + } else if (typeof filePath !== 'string') { + // Update currentFile file + if (filePath.name !== currentFile && filePath.path !== currentFile) { + handleFileChange(filePath); + } } - }, [filePath]); - - let currentFile = ''; - if (filePath) { - if (typeof filePath === 'string') { - currentFile = filePath; - } else { - currentFile = filePath.name; - } - } + }, [filePath, currentFile, handleFileChange, updateOptimizeState]); function updateOptimizeState(duration, size, isvid) { updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid }); @@ -179,9 +178,6 @@ function PublishFile(props: Props) { function handleFileChange(file: WebFile) { const { showToast } = props; window.URL = window.URL || window.webkitURL; - // if electron, we'll set filePath to the path string because SDK is handling publishing. - // if web, we set the filePath (dumb name) to the File() object - // File.path will be undefined from web due to browser security, so it will default to the File Object. setOversized(false); // select file, start to select a new one, then cancel @@ -189,7 +185,8 @@ function PublishFile(props: Props) { updatePublishForm({ filePath: '', name: '' }); return; } - // if video, extract duration so we can warn about bitrate + + // if video, extract duration so we can warn about bitrateif (typeof file !== 'string') { const contentType = file.type.split('/'); const isVideo = contentType[0] === 'video'; const isMp4 = contentType[1] === 'mp4'; @@ -212,17 +209,17 @@ function PublishFile(props: Props) { // @if TARGET='web' // we only need to enforce file sizes on 'web' - if (typeof file !== 'string') { - if (file && file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) { - setOversized(true); - showToast(__(UPLOAD_SIZE_MESSAGE)); - updatePublishForm({ filePath: '', name: '' }); - return; - } + if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) { + setOversized(true); + showToast(__(UPLOAD_SIZE_MESSAGE)); + updatePublishForm({ filePath: '', name: '' }); + return; } // @endif const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = { + // if electron, we'll set filePath to the path string because SDK is handling publishing. + // File.path will be undefined from web due to browser security, so it will default to the File Object. filePath: file.path || file, }; // Strip off extention and replace invalid characters @@ -232,6 +229,8 @@ function PublishFile(props: Props) { if (!isStillEditing) { publishFormParams.name = parsedFileName; } + // File path is not supported on web for security reasons so we use the name instead. + setCurrentFile(file.path || file.name); updatePublishForm(publishFormParams); } diff --git a/ui/constants/icons.js b/ui/constants/icons.js index 8ab697305..71cc20b03 100644 --- a/ui/constants/icons.js +++ b/ui/constants/icons.js @@ -100,3 +100,4 @@ export const SLIDERS = 'Sliders'; export const SCIENCE = 'Science'; export const ANALYTICS = 'BarChart2'; export const PURCHASED = 'Key'; +export const CIRCLE = 'Circle'; diff --git a/ui/constants/modal_types.js b/ui/constants/modal_types.js index 98fa706f5..e8e36c3a2 100644 --- a/ui/constants/modal_types.js +++ b/ui/constants/modal_types.js @@ -3,6 +3,7 @@ export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource'; export const COMMENT_ACKNOWEDGEMENT = 'comment_acknowlegement'; export const INCOMPATIBLE_DAEMON = 'incompatible_daemon'; export const FILE_TIMEOUT = 'file_timeout'; +export const FILE_SELECTION = 'file_selection'; export const DOWNLOADING = 'downloading'; export const AUTO_GENERATE_THUMBNAIL = 'auto_generate_thumbnail'; export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded'; diff --git a/ui/effects/use-drag-drop.js b/ui/effects/use-drag-drop.js new file mode 100644 index 000000000..110593e3d --- /dev/null +++ b/ui/effects/use-drag-drop.js @@ -0,0 +1,97 @@ +import React from 'react'; + +const LISTENER = { + ADD: 'add', + REMOVE: 'remove', +}; + +const DRAG_TYPES = { + END: 'dragend', + START: 'dragstart', + ENTER: 'dragenter', + LEAVE: 'dragleave', +}; + +const DRAG_SCORE = { + [DRAG_TYPES.ENTER]: 1, + [DRAG_TYPES.LEAVE]: -1, +}; + +const DRAG_STATE = { + [DRAG_TYPES.END]: false, + [DRAG_TYPES.START]: true, +}; + +// Returns simple detection for global drag-drop +export default function useFetched() { + const [drag, setDrag] = React.useState(false); + const [dropData, setDropData] = React.useState(null); + + React.useEffect(() => { + let dragCount = 0; + let draggingElement = false; + + // Handle file drop + const handleDropEvent = event => { + // Ignore non file types ( html elements / text ) + if (!draggingElement) { + event.stopPropagation(); + event.preventDefault(); + // Get files + const files = event.dataTransfer.files; + // Check for files + if (files.length > 0) { + setDropData(event.dataTransfer); + } + } + // Reset state ( hide drop zone ) + dragCount = 0; + setDrag(false); + }; + + // Drag event for non files type ( html elements / text ) + const handleDragElementEvent = event => { + draggingElement = DRAG_STATE[event.type]; + }; + + // Drag events + const handleDragEvent = event => { + event.stopPropagation(); + event.preventDefault(); + // Prevent multiple drop areas + dragCount += DRAG_SCORE[event.type]; + // Dragged file enters the drop area + if (dragCount === 1 && !draggingElement && event.type === DRAG_TYPES.ENTER) { + setDrag(true); + } + // Dragged file leaves the drop area + if (dragCount === 0 && event.type === DRAG_TYPES.LEAVE) { + setDrag(false); + } + }; + + // Register / Unregister listeners + const handleEventListeners = event => { + const action = `${event}EventListener`; + // Handle drop event + document[action]('drop', handleDropEvent); + // Handle drag events + document[action](DRAG_TYPES.ENTER, handleDragEvent); + document[action](DRAG_TYPES.LEAVE, handleDragEvent); + // Handle non files drag events + document[action](DRAG_TYPES.END, handleDragElementEvent); + document[action](DRAG_TYPES.START, handleDragElementEvent); + }; + // On component mounted: + // Register event listeners + handleEventListeners(LISTENER.ADD); + + // On component unmounted: + return () => { + // Unregister event listeners + handleEventListeners(LISTENER.REMOVE); + }; + }, []); + + return { drag, dropData }; +} diff --git a/ui/modal/modalFileSelection/index.js b/ui/modal/modalFileSelection/index.js new file mode 100644 index 000000000..6d0629177 --- /dev/null +++ b/ui/modal/modalFileSelection/index.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import { doHideModal } from 'redux/actions/app'; +import { doUpdatePublishForm } from 'lbry-redux'; + +import ModaFileSelection from './view'; + +const perform = dispatch => ({ + hideModal: props => dispatch(doHideModal(props)), + updatePublishForm: value => dispatch(doUpdatePublishForm(value)), +}); + +export default connect(null, perform)(ModaFileSelection); diff --git a/ui/modal/modalFileSelection/view.jsx b/ui/modal/modalFileSelection/view.jsx new file mode 100644 index 000000000..39c5c12f3 --- /dev/null +++ b/ui/modal/modalFileSelection/view.jsx @@ -0,0 +1,77 @@ +// @flow +import * as ICONS from 'constants/icons'; +import * as PAGES from 'constants/pages'; +import React from 'react'; +import { Modal } from 'modal/modal'; +import { withRouter } from 'react-router'; +import Card from 'component/common/card'; +import Button from 'component/button'; +import FileList from 'component/common/file-list'; + +type Props = { + files: Array, + hideModal: () => void, + updatePublishForm: ({}) => void, + history: { + location: { pathname: string }, + push: string => void, + }, +}; + +const PUBLISH_URL = `/$/${PAGES.PUBLISH}`; + +const ModalFileSelection = (props: Props) => { + const { history, files, hideModal, updatePublishForm } = props; + const [selectedFile, setSelectedFile] = React.useState(null); + + const navigateToPublish = React.useCallback(() => { + // Navigate only if location is not publish area: + // - Prevent spam in history + if (history.location.pathname !== PUBLISH_URL) { + history.push(PUBLISH_URL); + } + }, [history]); + + function handleCloseModal() { + hideModal(); + setSelectedFile(null); + } + + function handleSubmit() { + updatePublishForm({ filePath: selectedFile }); + handleCloseModal(); + navigateToPublish(); + } + + const handleFileChange = (file?: WebFile) => { + setSelectedFile(file); + }; + + return ( + + +
+ +
+
+
+ + } + /> +
+ ); +}; + +export default withRouter(ModalFileSelection); diff --git a/ui/modal/modalRouter/view.jsx b/ui/modal/modalRouter/view.jsx index 0e79030a0..70202e8ea 100644 --- a/ui/modal/modalRouter/view.jsx +++ b/ui/modal/modalRouter/view.jsx @@ -39,6 +39,7 @@ import ModalRepost from 'modal/modalRepost'; import ModalSignOut from 'modal/modalSignOut'; import ModalLiquidateSupports from 'modal/modalSupportsLiquidate'; import ModalConfirmAge from 'modal/modalConfirmAge'; +import ModalFileSelection from 'modal/modalFileSelection'; type Props = { modal: { id: string, modalProps: {} }, @@ -140,6 +141,8 @@ function ModalRouter(props: Props) { return ; case MODALS.CONFIRM_AGE: return ; + case MODALS.FILE_SELECTION: + return ; default: return null; } diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 06aa1bd8f..68d2bd5bc 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -18,6 +18,8 @@ @import 'component/embed-player'; @import 'component/expandable'; @import 'component/expanding-details'; +@import 'component/file-drop'; +@import 'component/file-list'; @import 'component/file-properties'; @import 'component/file-render'; @import 'component/footer'; diff --git a/ui/scss/component/_file-drop.scss b/ui/scss/component/_file-drop.scss new file mode 100644 index 000000000..a0ca4fc91 --- /dev/null +++ b/ui/scss/component/_file-drop.scss @@ -0,0 +1,39 @@ +.file-drop { + top: 0; + left: 0; + position: fixed; + background: var(--color-background-overlay); + opacity: 0; + height: 100%; + width: 100%; + overflow: hidden; + pointer-events: none; + z-index: 5; + transition: opacity 0.3s ease; + + display: flex; + align-items: center; + justify-content: center; + + &.file-drop--show { + opacity: 1; + pointer-events: all; + transition: opacity 0.3s ease; + } + + .file-drop__area { + display: flex; + min-width: 400px; + align-items: center; + flex-direction: column; + padding: var(--spacing-large); + + .main-icon { + margin: var(--spacing-small); + } + + p { + margin-bottom: var(--spacing-large); + } + } +} diff --git a/ui/scss/component/_file-list.scss b/ui/scss/component/_file-list.scss new file mode 100644 index 000000000..ed8647b65 --- /dev/null +++ b/ui/scss/component/_file-list.scss @@ -0,0 +1,6 @@ +.file-list { + width: 100%; + overflow: auto; + max-height: 220px; + padding: var(--spacing-small); +} diff --git a/ui/util/web-file-system.js b/ui/util/web-file-system.js new file mode 100644 index 000000000..8855beb7b --- /dev/null +++ b/ui/util/web-file-system.js @@ -0,0 +1,73 @@ +// Some functions to work with the new html5 file system API: + +// Wrapper for webkitGetAsEntry +// Note: webkitGetAsEntry might be renamed to GetAsEntry +const getAsEntry = item => { + if (item.kind === 'file' && item.webkitGetAsEntry) { + return item.webkitGetAsEntry(); + } +}; + +// Get file object from fileEntry +const getFile = fileEntry => new Promise((resolve, reject) => fileEntry.file(resolve, reject)); + +// Read entries from directory +const readDirectory = directory => { + // Some browsers don't support this + if (directory.createReader !== undefined) { + let dirReader = directory.createReader(); + return new Promise((resolve, reject) => dirReader.readEntries(resolve, reject)); + } +}; + +// Get file system entries from the dataTransfer items list: + +const getFiles = (items, directoryEntries = false) => { + let entries = []; + + for (let item of items) { + const entry = directoryEntries ? item : getAsEntry(item); + if (entry && entry.isFile) { + const file = getFile(entry); + entries.push(file); + } + } + + return Promise.all(entries); +}; + +// Generate a valid file tree from dataTransfer: +// - Ignores directory entries +// - Ignores recursive search +export const getTree = async dataTransfer => { + if (dataTransfer) { + const { items, files } = dataTransfer; + // Handle single item drop + if (files.length === 1) { + const root = getAsEntry(items[0]); + // Handle entry + if (root) { + // Handle directory + if (root.isDirectory) { + const directoryEntries = await readDirectory(root); + // Get each file from the list + return getFiles(directoryEntries, true); + } + // Hanlde file + if (root.isFile) { + const file = await getFile(root); + return [file]; + } + } else { + // Some files have hidden dataTransfer items: + // Use the default file object instead + return files; + } + } + // Handle multiple items drop + if (files.length > 1) { + // Convert items to fileEntry and filter files + return getFiles(items); + } + } +};