add in-app text and markdown publishing

This commit is contained in:
btzr-io 2020-07-27 18:12:59 -05:00 committed by Sean Yesmunt
parent 80d8eeb4cf
commit a5d1746151
9 changed files with 354 additions and 40 deletions

View file

@ -3,7 +3,6 @@ import { doUpdatePublishForm, makeSelectPublishFormValue } from 'lbry-redux';
import PublishPage from './view';
const select = state => ({
title: makeSelectPublishFormValue('title')(state),
description: makeSelectPublishFormValue('description')(state),
});
@ -11,7 +10,4 @@ const perform = dispatch => ({
updatePublishForm: value => dispatch(doUpdatePublishForm(value)),
});
export default connect(
select,
perform
)(PublishPage);
export default connect(select, perform)(PublishPage);

View file

@ -7,14 +7,13 @@ import usePersistedState from 'effects/use-persisted-state';
import Card from 'component/common/card';
type Props = {
title: ?string,
description: ?string,
disabled: boolean,
updatePublishForm: ({}) => void,
};
function PublishText(props: Props) {
const { title, description, updatePublishForm, disabled } = props;
const { description, updatePublishForm, disabled } = props;
const [advancedEditor, setAdvancedEditor] = usePersistedState('publish-form-description-mode', false);
function toggleMarkdown() {
setAdvancedEditor(!advancedEditor);
@ -24,16 +23,6 @@ function PublishText(props: Props) {
<Card
actions={
<React.Fragment>
<FormField
type="text"
name="content_title"
label={__('Title')}
placeholder={__('Descriptive titles work best')}
disabled={disabled}
value={title}
onChange={e => updatePublishForm({ title: e.target.value })}
/>
<FormField
type={!SIMPLE_SITE && advancedEditor ? 'markdown' : 'textarea'}
name="content_description"

View file

@ -12,6 +12,7 @@ import PublishPage from './view';
const select = state => ({
name: makeSelectPublishFormValue('name')(state),
title: makeSelectPublishFormValue('title')(state),
filePath: makeSelectPublishFormValue('filePath')(state),
optimize: makeSelectPublishFormValue('optimize')(state),
isStillEditing: selectIsStillEditing(state),

View file

@ -2,6 +2,7 @@
import * as ICONS from 'constants/icons';
import React, { useState, useEffect } from 'react';
import { regexInvalidURI } from 'lbry-redux';
import StoryEditor from 'component/storyEditor';
import FileSelector from 'component/common/file-selector';
import Button from 'component/button';
import Card from 'component/common/card';
@ -9,9 +10,13 @@ import { FormField } from 'component/common/form';
import Spinner from 'component/spinner';
import I18nMessage from 'component/i18nMessage';
import usePersistedState from 'effects/use-persisted-state';
import * as PUBLISH_MODES from 'constants/publish_types';
type Props = {
uri: ?string,
mode: ?string,
name: ?string,
title: ?string,
filePath: string | WebFile,
isStillEditing: boolean,
balance: number,
@ -26,11 +31,16 @@ type Props = {
size: number,
duration: number,
isVid: boolean,
setPublishMode: string => void,
setPrevFileText: string => void,
};
function PublishFile(props: Props) {
const {
uri,
mode,
name,
title,
balance,
filePath,
isStillEditing,
@ -44,11 +54,14 @@ function PublishFile(props: Props) {
size,
duration,
isVid,
setPublishMode,
setPrevFileText,
} = props;
const ffmpegAvail = ffmpegStatus.available;
const [oversized, setOversized] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const [currentFileType, setCurrentFileType] = useState(null);
const [optimizeAvail, setOptimizeAvail] = useState(false);
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
@ -58,11 +71,20 @@ function PublishFile(props: Props) {
const PROCESSING_MB_PER_SECOND = 0.5;
const MINUTES_THRESHOLD = 30;
const HOURS_THRESHOLD = MINUTES_THRESHOLD * 60;
const MARKDOWN_FILE_EXTENSIONS = ['txt', 'md', 'markdown'];
const sizeInMB = Number(size) / 1000000;
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND;
// clear warnings
// Reset filePath if publish mode changed
useEffect(() => {
if (mode === PUBLISH_MODES.STORY) {
if (currentFileType !== 'text/markdown') {
updatePublishForm({ filePath: '', name: '' });
}
}
}, [currentFileType, mode, updatePublishForm]);
useEffect(() => {
if (!filePath || filePath === '') {
setCurrentFile('');
@ -82,7 +104,7 @@ function PublishFile(props: Props) {
setOptimizeAvail(isOptimizeAvail);
updatePublishForm({ optimize: finalOptimizeState });
}, [currentFile, filePath, isVid, ffmpegAvail, userOptimize]);
}, [currentFile, filePath, isVid, ffmpegAvail, userOptimize, updatePublishForm]);
function updateFileInfo(duration, size, isvid) {
updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid });
@ -201,6 +223,19 @@ function PublishFile(props: Props) {
const contentType = file.type && file.type.split('/');
const isVideo = contentType && contentType[0] === 'video';
const isMp4 = contentType && contentType[1] === 'mp4';
let isMarkdownText = false;
if (contentType) {
isMarkdownText = contentType[0] === 'text';
setCurrentFileType(contentType);
} else if (file.name) {
// If user's machine is missign a valid content type registration
// for markdown content: text/markdown, file extension will be used instead
const extension = file.name.split('.').pop();
isMarkdownText = MARKDOWN_FILE_EXTENSIONS.includes(extension);
}
if (isVideo) {
if (isMp4) {
const video = document.createElement('video');
@ -220,6 +255,22 @@ function PublishFile(props: Props) {
updateFileInfo(0, file.size, isVideo);
}
if (isMarkdownText) {
// Create reader
const reader = new FileReader();
// Handler for file reader
reader.addEventListener('load', event => {
const text = event.target.result;
updatePublishForm({ fileText: text });
setPublishMode(PUBLISH_MODES.STORY);
});
// Read file contents
reader.readAsText(file);
setCurrentFileType('text/markdown');
} else {
setPublishMode(PUBLISH_MODES.FILE);
}
// @if TARGET='web'
// we only need to enforce file sizes on 'web'
if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) {
@ -247,43 +298,62 @@ function PublishFile(props: Props) {
updatePublishForm(publishFormParams);
}
let title;
let cardTitle;
if (publishing) {
title = (
cardTitle = (
<span>
{__('Uploading')}
<Spinner type={'small'} />
</span>
);
} else {
title = isStillEditing ? __('Edit') : __('Upload');
cardTitle = isStillEditing ? __('Edit') : __('Upload');
}
const isPublishFile = mode === PUBLISH_MODES.FILE;
const isPublishStory = mode === PUBLISH_MODES.STORY;
return (
<Card
icon={ICONS.PUBLISH}
disabled={disabled || balance === 0}
title={
<React.Fragment>
{title}{' '}
{cardTitle}{' '}
{inProgress && <Button button="close" label={__('Cancel')} icon={ICONS.REMOVE} onClick={clearPublish} />}
</React.Fragment>
}
subtitle={isStillEditing && __('You are currently editing your upload.')}
actions={
<React.Fragment>
<FileSelector disabled={disabled} currentPath={currentFile} onFileChosen={handleFileChange} />
{getMessage()}
{/* @if TARGET='app' */}
<FormField
type="checkbox"
checked={userOptimize}
disabled={!optimizeAvail}
onChange={() => setUserOptimize(!userOptimize)}
label={__('Optimize and transcode video')}
name="optimize"
type="text"
name="content_title"
label={__('Title')}
placeholder={__('Descriptive titles work best')}
disabled={disabled}
value={title}
onChange={e => updatePublishForm({ title: e.target.value })}
/>
{!ffmpegAvail && (
{isPublishFile && (
<FileSelector disabled={disabled} currentPath={currentFile} onFileChosen={handleFileChange} />
)}
{isPublishStory && (
<StoryEditor label={__('Story content')} uri={uri} disabled={disabled} setPrevFileText={setPrevFileText} />
)}
{isPublishFile && getMessage()}
{/* @if TARGET='app' */}
{isPublishFile && (
<FormField
type="checkbox"
checked={userOptimize}
disabled={!optimizeAvail}
onChange={() => setUserOptimize(!userOptimize)}
label={__('Optimize and transcode video')}
name="optimize"
/>
)}
{isPublishFile && !ffmpegAvail && (
<p className="help">
<I18nMessage
tokens={{
@ -294,7 +364,7 @@ function PublishFile(props: Props) {
</I18nMessage>
</p>
)}
{Boolean(size) && ffmpegAvail && optimize && isVid && (
{isPublishFile && Boolean(size) && ffmpegAvail && optimize && isVid && (
<p className="help">
<I18nMessage
tokens={{

View file

@ -7,6 +7,8 @@
On web, the Lbry publish method call is overridden in platform/web/api-setup, using a function in platform/web/publish.
File upload is carried out in the background by that function.
*/
import fs from 'fs';
import { remote } from 'electron';
import { SITE_NAME } from 'config';
import { CHANNEL_NEW, CHANNEL_ANONYMOUS } from 'constants/claim';
import React, { useEffect } from 'react';
@ -15,7 +17,7 @@ import Button from 'component/button';
import SelectChannel from 'component/selectChannel';
import classnames from 'classnames';
import TagsSelect from 'component/tagsSelect';
import PublishText from 'component/publishText';
import PublishDescription from 'component/publishDescription';
import PublishPrice from 'component/publishPrice';
import PublishFile from 'component/publishFile';
import PublishName from 'component/publishName';
@ -24,12 +26,18 @@ import PublishFormErrors from 'component/publishFormErrors';
import SelectThumbnail from 'component/selectThumbnail';
import Card from 'component/common/card';
import I18nMessage from 'component/i18nMessage';
import * as PUBLISH_MODES from 'constants/publish_types';
const { dialog } = remote;
const currentWindow = remote.getCurrentWindow();
const MODES = Object.values(PUBLISH_MODES);
type Props = {
disabled: boolean,
tags: Array<Tag>,
publish: (?string) => void,
filePath: ?string,
fileText: ?string,
bid: ?number,
bidError: ?string,
editingURI: ?string,
@ -73,6 +81,13 @@ type Props = {
};
function PublishForm(props: Props) {
const [mode, setMode] = React.useState(PUBLISH_MODES.FILE);
const [autoSwitchMode, setAutoSwitchMode] = React.useState(true);
// Used to checl if the file has been modified by user
const [fileEdited, setFileEdited] = React.useState(false);
const [prevFileText, setPrevFileText] = React.useState('');
const {
thumbnail,
name,
@ -87,6 +102,7 @@ function PublishForm(props: Props) {
resetThumbnailStatus,
updatePublishForm,
filePath,
fileText,
publishing,
clearPublish,
isStillEditing,
@ -97,6 +113,7 @@ function PublishForm(props: Props) {
onChannelChange,
ytSignupPending,
} = props;
const TAGS_LIMIT = 5;
const formDisabled = (!filePath && !editingURI) || publishing;
const isInProgress = filePath || editingURI || name || title;
@ -129,6 +146,15 @@ function PublishForm(props: Props) {
}
}, [thumbnail, resetThumbnailStatus]);
// Check for content changes on the text editor
useEffect(() => {
if (!fileEdited && fileText !== prevFileText && fileText !== '') {
setFileEdited(true);
} else if (fileEdited && fileText === prevFileText) {
setFileEdited(false);
}
}, [fileText, prevFileText, fileEdited]);
// Every time the channel or name changes, resolve the uris to find winning bid amounts
useEffect(() => {
// If they are midway through a channel creation, treat it as anonymous until it completes
@ -161,14 +187,127 @@ function PublishForm(props: Props) {
updatePublishForm({ channel });
}
function showSaveDialog() {
return dialog.showSaveDialog(currentWindow, {
filters: [{ name: 'Text', extensions: ['md', 'markdown', 'txt'] }],
});
}
function createWebFile() {
if (fileText) {
const fileName = name || title || 'story';
return new File([fileText], `${fileName}.md`, { type: 'text/markdown' });
}
}
async function saveFileChanges() {
let output = filePath;
if (!output || output === '') {
output = await showSaveDialog();
}
// User saved the file on a custom location
if (typeof output === 'string') {
// Save file changes
return new Promise((resolve, reject) => {
fs.writeFile(output, fileText, (error, data) => {
// Handle error, cant save changes or create file
error ? reject(error) : resolve(output);
});
});
}
}
function verifyStoryContent() {
const isEmpty = !fileText || fileText.length === 0 || fileText === '';
// TODO: Verify file size limit, and character size as well ?
return !isEmpty;
}
async function handlePublish() {
// Publish story:
// If here is no file selected yet on desktop, show file dialog and let the
// user choose a file path. On web a new File is created
if (mode === PUBLISH_MODES.STORY) {
let outputFile = filePath;
// If user modified content on the text editor:
// Save changes and updat file path
if (fileEdited) {
// @if TARGET='app'
outputFile = await saveFileChanges();
// @endif
// @if TARGET='web'
outputFile = createWebFile();
// @endif
// New content stored locally and is not empty
if (outputFile) {
updatePublishForm({ filePath: outputFile });
}
}
// Verify if story has a valid content and is not emoty
// On web file size limit will be verified as well
const verified = verifyStoryContent();
if (verified) {
publish(outputFile);
}
}
// Publish file
if (mode === PUBLISH_MODES.FILE) {
publish(filePath);
}
}
function changePublishMode(name) {
setMode(name);
}
// Update mode on editing
useEffect(() => {
if (autoSwitchMode && editingURI && myClaimForUri) {
const { media_type: mediaType } = myClaimForUri.value.source;
// Change publish mode to "story" if editing content type is markdown
if (mediaType === 'text/markdown' && mode !== PUBLISH_MODES.STORY) {
setMode(PUBLISH_MODES.STORY);
// Prevent forced mode
setAutoSwitchMode(false);
}
}
}, [autoSwitchMode, editingURI, myClaimForUri, mode, setMode, setAutoSwitchMode]);
// Editing claim uri
const uri = myClaimForUri ? myClaimForUri.permanent_url : undefined;
return (
<div className="card-stack">
<PublishFile disabled={disabled || publishing} inProgress={isInProgress} />
<div className="button-tab-group">
{MODES.map((name, index) => (
<Button
key={index}
icon={name}
label={name}
button="alt"
onClick={() => {
changePublishMode(name);
}}
className={classnames('button-toggle', { 'button-toggle--active': mode === name })}
/>
))}
</div>
<PublishFile
uri={uri}
mode={mode}
disabled={disabled || publishing}
inProgress={isInProgress}
setPublishMode={setMode}
setPrevFileText={setPrevFileText}
/>
{!publishing && (
<div className={classnames({ 'card--disabled': formDisabled })}>
<PublishText disabled={formDisabled} />
{mode === PUBLISH_MODES.FILE && <PublishDescription disabled={formDisabled} />}
<Card actions={<SelectThumbnail />} />
<TagsSelect
suggestMature
disableAutoFocus
@ -217,7 +356,7 @@ function PublishForm(props: Props) {
<div className="card__actions">
<Button
button="primary"
onClick={() => publish(filePath)}
onClick={handlePublish}
label={submitLabel}
disabled={
formDisabled || !formValid || uploadThumbnailStatus === THUMBNAIL_STATUSES.IN_PROGRESS || ytSignupPending

View file

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import {
selectIsStillEditing,
makeSelectPublishFormValue,
doUpdatePublishForm,
makeSelectFileInfoForUri,
} from 'lbry-redux';
import StoryEditor from './view';
const select = (state, props) => ({
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
filePath: makeSelectPublishFormValue('filePath')(state),
fileText: makeSelectPublishFormValue('fileText')(state),
isStillEditing: selectIsStillEditing(state),
});
const perform = dispatch => ({
updatePublishForm: value => dispatch(doUpdatePublishForm(value)),
});
export default connect(select, perform)(StoryEditor);

View file

@ -0,0 +1,91 @@
// @flow
import fs from 'fs';
import React, { useEffect } from 'react';
import { SIMPLE_SITE } from 'config';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import { FormField } from 'component/common/form';
import usePersistedState from 'effects/use-persisted-state';
type Props = {
uri: ?string,
label: ?string,
disabled: ?boolean,
fileInfo: FileListItem,
filePath: string | WebFile,
fileText: ?string,
isStillEditing: boolean,
setPrevFileText: string => void,
updatePublishForm: ({}) => void,
};
function StoryEditor(props: Props) {
const {
uri,
label,
disabled,
fileInfo,
filePath,
fileText,
isStillEditing,
setPrevFileText,
updatePublishForm,
} = props;
const [advancedEditor, setAdvancedEditor] = usePersistedState('publish-form-story-mode', false);
function toggleMarkdown() {
setAdvancedEditor(!advancedEditor);
}
useEffect(() => {
// @if TARGET='app'
function readFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (error, data) => {
error ? reject(error) : resolve(data);
});
});
}
async function updateEditorText(path) {
const text = await readFile(path);
if (text) {
// Store original content
setPrevFileText(text);
// Update text editor form
updatePublishForm({ fileText: text });
}
}
const isEditingFile = isStillEditing && uri && fileInfo;
if (isEditingFile) {
const { mime_type: mimeType, download_path: downloadPath } = fileInfo;
// Editing same file (previously published)
// User can use a different file to replace the content
if (!filePath && mimeType === 'text/markdown') {
updateEditorText(downloadPath);
}
}
// @endif
}, [uri, isStillEditing, filePath, fileInfo, setPrevFileText, updatePublishForm]);
return (
<FormField
type={!SIMPLE_SITE && advancedEditor ? 'markdown' : 'textarea'}
name="content_story"
label={label}
placeholder={__('My content for this story...')}
value={fileText}
disabled={disabled}
onChange={value => updatePublishForm({ fileText: advancedEditor ? value : value.target.value })}
quickActionLabel={advancedEditor ? __('Simple Editor') : __('Advanced Editor')}
quickActionHandler={toggleMarkdown}
textAreaMaxLength={FF_MAX_CHARS_IN_DESCRIPTION}
/>
);
}
export default StoryEditor;

View file

@ -0,0 +1,2 @@
export const FILE = 'File';
export const STORY = 'Story';

View file

@ -251,7 +251,12 @@ svg + .button__label,
margin-top: -4px;
}
.button-group {
.button-tab-group {
margin-bottom: var(--spacing-l);
}
.button-group,
.button-tab-group {
display: flex;
.button:first-child:not(:only-child) {