lbry-desktop/ui/component/channelEdit/view.jsx
saltrafael b256a4396b
Thumbnail upload fixes (#6860)
more improvements, fix url, do the same for cover

remember url, error if invalid

unneeded addition

Fix delayed message

Lint

Allow empty values (placeholder and Gerbil)

Fix filepath crash

Fix button
2021-08-24 20:28:23 -04:00

539 lines
19 KiB
JavaScript

// @flow
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import { FormField } from 'component/common/form';
import Button from 'component/button';
import TagsSearch from 'component/tagsSearch';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import ErrorText from 'component/common/error-text';
import ChannelThumbnail from 'component/channelThumbnail';
import { isNameValid, parseURI } from 'lbry-redux';
import ClaimAbandonButton from 'component/claimAbandonButton';
import { useHistory } from 'react-router-dom';
import { MINIMUM_PUBLISH_BID, INVALID_NAME_ERROR, ESTIMATED_FEE } from 'constants/claim';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import Card from 'component/common/card';
import * as PAGES from 'constants/pages';
import analytics from 'analytics';
import LbcSymbol from 'component/common/lbc-symbol';
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { SIMPLE_SITE } from 'config';
import { sortLanguageMap } from 'util/default-languages';
import ThumbnailBrokenImage from 'component/selectThumbnail/thumbnail-broken.png';
import Gerbil from 'component/channelThumbnail/gerbil.png';
const LANG_NONE = 'none';
const MAX_TAG_SELECT = 5;
type Props = {
claim: ChannelClaim,
title: string,
amount: number,
coverUrl: string,
thumbnailUrl: string,
location: { search: string },
description: string,
website: string,
email: string,
balance: number,
tags: Array<string>,
locations: Array<string>,
languages: Array<string>,
updateChannel: (any) => Promise<any>,
updatingChannel: boolean,
updateError: string,
createChannel: (any) => Promise<any>,
createError: string,
creatingChannel: boolean,
clearChannelErrors: () => void,
claimInitialRewards: () => void,
onDone: () => void,
openModal: (
id: string,
{ onUpdate: (string, boolean) => void, assetName: string, helpText: string, currentValue: string, title: string }
) => void,
uri: string,
disabled: boolean,
isClaimingInitialRewards: boolean,
hasClaimedInitialRewards: boolean,
};
function ChannelForm(props: Props) {
const {
uri,
claim,
amount,
title,
description,
website,
email,
thumbnailUrl,
coverUrl,
tags,
locations,
languages = [],
onDone,
updateChannel,
updateError,
updatingChannel,
createChannel,
creatingChannel,
createError,
clearChannelErrors,
claimInitialRewards,
openModal,
disabled,
isClaimingInitialRewards,
hasClaimedInitialRewards,
} = props;
const [nameError, setNameError] = React.useState(undefined);
const [bidError, setBidError] = React.useState('');
const [isUpload, setIsUpload] = React.useState({ cover: false, thumbnail: false });
const [coverError, setCoverError] = React.useState(false);
const [thumbError, setThumbError] = React.useState(false);
const { claim_id: claimId } = claim || {};
const [params, setParams]: [any, (any) => void] = React.useState(getChannelParams());
const { channelName } = parseURI(uri);
const name = params.name;
const isNewChannel = !uri;
const { replace } = useHistory();
const languageParam = params.languages;
const primaryLanguage = Array.isArray(languageParam) && languageParam.length && languageParam[0];
const secondaryLanguage = Array.isArray(languageParam) && languageParam.length >= 2 && languageParam[1];
const submitLabel = React.useMemo(() => {
if (isClaimingInitialRewards) {
return __('Claiming credits...');
}
return creatingChannel || updatingChannel ? __('Submitting') : __('Submit');
}, [isClaimingInitialRewards, creatingChannel, updatingChannel]);
const submitDisabled = React.useMemo(() => {
return (
isClaimingInitialRewards ||
creatingChannel ||
updatingChannel ||
nameError ||
thumbError ||
coverError ||
bidError ||
(isNewChannel && !params.name)
);
}, [isClaimingInitialRewards, creatingChannel, updatingChannel, nameError, thumbError, coverError, bidError, isNewChannel, params.name]);
function getChannelParams() {
// fill this in with sdk data
const channelParams: {
website: string,
email: string,
coverUrl: string,
thumbnailUrl: string,
description: string,
title: string,
amount: number,
languages: ?Array<string>,
locations: ?Array<string>,
tags: ?Array<{ name: string }>,
claim_id?: string,
} = {
website,
email,
coverUrl,
thumbnailUrl,
description,
title,
amount: amount || 0.001,
languages: languages || [],
locations: locations || [],
tags: tags
? tags.map((tag) => {
return { name: tag };
})
: [],
};
if (claimId) {
channelParams['claim_id'] = claimId;
}
return channelParams;
}
function handleBidChange(bid: number) {
const { balance, amount } = props;
const totalAvailableBidAmount = (parseFloat(amount) || 0.0) + (parseFloat(balance) || 0.0);
setParams({ ...params, amount: bid });
if (bid <= 0.0 || isNaN(bid)) {
setBidError(__('Deposit cannot be 0'));
} else if (totalAvailableBidAmount < bid) {
setBidError(
__('Deposit cannot be higher than your available balance: %balance%', { balance: totalAvailableBidAmount })
);
} else if (totalAvailableBidAmount - bid < ESTIMATED_FEE) {
setBidError(__('Please decrease your deposit to account for transaction fees'));
} else if (bid < MINIMUM_PUBLISH_BID) {
setBidError(__('Your deposit must be higher'));
} else {
setBidError('');
}
}
function handleLanguageChange(index, code) {
let langs = [...languageParam];
if (index === 0) {
if (code === LANG_NONE) {
// clear all
langs = [];
} else {
langs[0] = code;
}
} else {
if (code === LANG_NONE || code === langs[0]) {
langs.splice(1, 1);
} else {
langs[index] = code;
}
}
setParams({ ...params, languages: langs });
}
function handleThumbnailChange(thumbnailUrl: string, uploadSelected: boolean) {
setParams({ ...params, thumbnailUrl });
setIsUpload({ ...isUpload, thumbnail: uploadSelected });
setThumbError(false);
}
function handleCoverChange(coverUrl: string, uploadSelected: boolean) {
setParams({ ...params, coverUrl });
setIsUpload({ ...isUpload, cover: uploadSelected });
setCoverError(false);
}
function handleSubmit() {
if (uri) {
updateChannel(params).then((success) => {
if (success) {
onDone();
}
});
} else {
createChannel(params).then((success) => {
if (success) {
analytics.apiLogPublish(success);
onDone();
}
});
}
}
const LIMIT_ERR_PARTIAL_MSG = 'bad-txns-claimscriptsize-toolarge (code 16)';
let errorMsg = updateError || createError;
if (errorMsg && errorMsg.includes(LIMIT_ERR_PARTIAL_MSG)) {
errorMsg = __('Transaction limit reached. Try reducing the Description length.');
}
if ((!isUpload.thumbnail && thumbError) || (!isUpload.cover && coverError)) {
errorMsg = __('Invalid %error_type%', { error_type: (thumbError && 'thumbnail') || (coverError && 'cover image') });
}
React.useEffect(() => {
let nameError;
if (!name && name !== undefined) {
nameError = __('A name is required for your url');
} else if (!isNameValid(name, false)) {
nameError = INVALID_NAME_ERROR;
}
setNameError(nameError);
}, [name]);
React.useEffect(() => {
clearChannelErrors();
}, [clearChannelErrors]);
React.useEffect(() => {
if (!hasClaimedInitialRewards) {
claimInitialRewards();
}
}, [hasClaimedInitialRewards, claimInitialRewards]);
const coverSrc = coverError ? ThumbnailBrokenImage : params.coverUrl;
let thumbnailPreview;
if (!params.thumbnailUrl) {
thumbnailPreview = Gerbil;
} else if (thumbError) {
thumbnailPreview = ThumbnailBrokenImage;
} else {
thumbnailPreview = params.thumbnailUrl;
}
// TODO clear and bail after submit
return (
<>
<div className={classnames('main--contained', { 'card--disabled': disabled })}>
<header className="channel-cover">
<div className="channel__quick-actions">
<Button
button="alt"
title={__('Cover')}
onClick={() =>
openModal(MODALS.IMAGE_UPLOAD, {
onUpdate: (coverUrl, isUpload) => handleCoverChange(coverUrl, isUpload),
title: __('Edit Cover Image'),
helpText: __('(6.25:1)'),
assetName: __('Cover Image'),
currentValue: params.coverUrl,
})
}
icon={ICONS.CAMERA}
iconSize={18}
/>
</div>
{params.coverUrl &&
(coverError && isUpload.cover ? (
<div className="channel-cover__custom--waiting">{__('This will be visible in a few minutes.')}</div>
) : (
<img
className="channel-cover__custom"
src={coverSrc}
onError={() => setCoverError(true)}
/>
)
)}
<div className="channel__primary-info">
<div className="channel__edit-thumb">
<Button
button="alt"
title={__('Edit')}
onClick={() =>
openModal(MODALS.IMAGE_UPLOAD, {
onUpdate: (thumbnailUrl, isUpload) => handleThumbnailChange(thumbnailUrl, isUpload),
title: __('Edit Thumbnail Image'),
helpText: __('(1:1)'),
assetName: __('Thumbnail'),
currentValue: params.thumbnailUrl,
})
}
icon={ICONS.CAMERA}
iconSize={18}
/>
</div>
<ChannelThumbnail
className="channel__thumbnail--channel-page"
uri={uri}
thumbnailPreview={thumbnailPreview}
allowGifs
showDelayedMessage={isUpload.thumbnail}
setThumbError={(v) => setThumbError(v)}
thumbError={thumbError}
/>
<h1 className="channel__title">
{params.title || (channelName && '@' + channelName) || (params.name && '@' + params.name)}
</h1>
</div>
<div className="channel-cover__gradient" />
</header>
<Tabs>
<TabList className="tabs__list--channel-page">
<Tab>{__('General')}</Tab>
<Tab>{__('Credit Details')}</Tab>
<Tab>{__('Tags')}</Tab>
<Tab>{__('Other')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Card
body={
<>
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="channel_name">{__('Name')}</label>
<div className="form-field__prefix">@</div>
</fieldset-section>
<FormField
autoFocus={isNewChannel}
type="text"
name="channel_name"
placeholder={__('MyAwesomeChannel')}
value={params.name || channelName}
error={nameError}
disabled={!isNewChannel}
onChange={(e) => setParams({ ...params, name: e.target.value })}
/>
</fieldset-group>
{!isNewChannel && <span className="form-field__help">{__('This field cannot be changed.')}</span>}
<FormField
type="text"
name="channel_title2"
label={__('Title')}
placeholder={__('My Awesome Channel')}
value={params.title}
onChange={(e) => setParams({ ...params, title: e.target.value })}
/>
<FormField
type="markdown"
name="content_description2"
label={__('Description')}
placeholder={__('Description of your content')}
value={params.description}
onChange={(text) => setParams({ ...params, description: text })}
textAreaMaxLength={FF_MAX_CHARS_IN_DESCRIPTION}
/>
</>
}
/>
</TabPanel>
<TabPanel>
<Card
body={
<FormField
className="form-field--price-amount"
type="number"
name="content_bid2"
step="any"
label={<LbcSymbol postfix={__('Deposit')} size={14} />}
value={params.amount}
error={bidError}
min="0.0"
disabled={false}
onChange={(event) => handleBidChange(parseFloat(event.target.value))}
placeholder={0.1}
helper={
<>
{__('Increasing your deposit can help your channel be discovered more easily.')}
<WalletSpendableBalanceHelp inline />
</>
}
/>
}
/>
</TabPanel>
<TabPanel>
<Card
body={
<TagsSearch
suggestMature={!SIMPLE_SITE}
disableAutoFocus
disableControlTags
limitSelect={MAX_TAG_SELECT}
tagsPassedIn={params.tags || []}
label={__('Selected Tags')}
onRemove={(clickedTag) => {
const newTags = params.tags.slice().filter((tag) => tag.name !== clickedTag.name);
setParams({ ...params, tags: newTags });
}}
onSelect={(newTags) => {
newTags.forEach((newTag) => {
if (!params.tags.map((savedTag) => savedTag.name).includes(newTag.name)) {
setParams({ ...params, tags: [...params.tags, newTag] });
} else {
// If it already exists and the user types it in, remove it
setParams({ ...params, tags: params.tags.filter((tag) => tag.name !== newTag.name) });
}
});
}}
/>
}
/>
</TabPanel>
<TabPanel>
<Card
body={
<>
<FormField
type="text"
name="channel_website2"
label={__('Website')}
placeholder={__('aprettygoodsite.com')}
disabled={false}
value={params.website}
onChange={(e) => setParams({ ...params, website: e.target.value })}
/>
<FormField
type="text"
name="content_email2"
label={__('Email')}
placeholder={__('yourstruly@example.com')}
disabled={false}
value={params.email}
onChange={(e) => setParams({ ...params, email: e.target.value })}
/>
<FormField
name="language_select"
type="select"
label={__('Primary Language')}
onChange={(event) => handleLanguageChange(0, event.target.value)}
value={primaryLanguage}
helper={__('Your main content language')}
>
<option key={'pri-langNone'} value={LANG_NONE}>
{__('None selected')}
</option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
<option key={langKey} value={langKey}>
{langName}
</option>
))}
</FormField>
<FormField
name="language_select2"
type="select"
label={__('Secondary Language')}
onChange={(event) => handleLanguageChange(1, event.target.value)}
value={secondaryLanguage}
disabled={!languageParam[0]}
helper={__('Your other content language')}
>
<option key={'sec-langNone'} value={LANG_NONE}>
{__('None selected')}
</option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
<option key={langKey} value={langKey} disabled={langKey === languageParam[0]}>
{langName}
</option>
))}
</FormField>
</>
}
/>
</TabPanel>
</TabPanels>
</Tabs>
<Card
className="card--after-tabs"
actions={
<>
<div className="section__actions">
<Button button="primary" disabled={submitDisabled} label={submitLabel} onClick={handleSubmit} />
<Button button="link" label={__('Cancel')} onClick={onDone} />
</div>
{errorMsg ? (
<ErrorText>{errorMsg}</ErrorText>
) : (
<p className="help">
{__('After submitting, it will take a few minutes for your changes to be live for everyone.')}
</p>
)}
{!isNewChannel && (
<div className="section__actions">
<ClaimAbandonButton uri={uri} abandonActionCallback={() => replace(`/$/${PAGES.CHANNELS}`)} />
</div>
)}
</>
}
/>
</div>
</>
);
}
export default ChannelForm;