Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support drag-and-drop file publishing #4170

Merged
merged 14 commits into from
May 25, 2020
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion static/app-strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%. "
}
}
2 changes: 2 additions & 0 deletions ui/component/app/view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -284,6 +285,7 @@ function App(props: Props) {
<React.Fragment>
<Router />
<ModalRouter />
<FileDrop />
<FileRenderFloating />
{isEnhancedLayout && <Yrbl className="yrbl--enhanced" />}

Expand Down
74 changes: 74 additions & 0 deletions ui/component/common/file-list.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @flow
import React from 'react';
import { useRadioState, Radio, RadioGroup } from 'reakit/Radio';

type Props = {
files: Array<WebFile>,
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) => (
<span className="radio">
<input {...props} type="radio" ref={ref} />
<label htmlFor={props.id}>{props.label}</label>
</span>
));

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 (
<div className={'file-list'}>
<RadioGroup {...radio} aria-label="files">
btzr-io marked this conversation as resolved.
Show resolved Hide resolved
{files.map(({ name }) => {
return <Radio key={name} {...radio} value={name} label={name} as={ForwardedRadio} />;
})}
</RadioGroup>
</div>
);
}

export default FileList;
6 changes: 6 additions & 0 deletions ui/component/common/icon-custom.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,4 +623,10 @@ export const icons = {
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</g>
),
[ICONS.COMPLETED]: buildIcon(
<g>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</g>
),
};
20 changes: 20 additions & 0 deletions ui/component/fileDrop/index.js
Original file line number Diff line number Diff line change
@@ -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);
162 changes: 162 additions & 0 deletions ui/component/fileDrop/view.jsx
Original file line number Diff line number Diff line change
@@ -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<WebFile> }) => 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 = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels really smooth 👍

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 (
<div className={classnames('file-drop', show && 'file-drop--show')}>
<div className={classnames('card', 'file-drop__area')}>
<Icon size={64} icon={icon} className={'main-icon'} />
<p>{target ? target.name : __(`Drop here to publish!`)} </p>
</div>
</div>
);
}

export default withRouter(FileDrop);
43 changes: 21 additions & 22 deletions ui/component/publishFile/view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand All @@ -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 });
Expand Down Expand Up @@ -179,17 +178,15 @@ 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
if (!file) {
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';
Expand All @@ -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
Expand All @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions ui/constants/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,4 @@ export const SLIDERS = 'Sliders';
export const SCIENCE = 'Science';
export const ANALYTICS = 'BarChart2';
export const PURCHASED = 'Key';
export const CIRCLE = 'Circle';