New repost flow

Clearer display of takeover amounts
Repost from empty search result, from top page, or from claim

review changes

final touches

bump

empty comment copy

they

emptier

validation cleanup

extra
This commit is contained in:
zeppi 2020-12-03 19:10:23 -05:00
parent 73dea00e41
commit 99ab165a8f
31 changed files with 938 additions and 75 deletions

View file

@ -1527,6 +1527,9 @@
"Explore": "Explore", "Explore": "Explore",
"There is a bug... somewhere": "There is a bug... somewhere", "There is a bug... somewhere": "There is a bug... somewhere",
"Try refreshing to fix the issue. If that doesn't work, email help@lbry.com for support.": "Try refreshing to fix the issue. If that doesn't work, email help@lbry.com for support.", "Try refreshing to fix the issue. If that doesn't work, email help@lbry.com for support.": "Try refreshing to fix the issue. If that doesn't work, email help@lbry.com for support.",
"No Results": "No Results",
"Content preview": "Content preview",
"Repost url": "Repost url",
"Close sidebar - hide channels you are following": "Close sidebar - hide channels you are following", "Close sidebar - hide channels you are following": "Close sidebar - hide channels you are following",
"Expand sidebar - view channels you are following.": "Expand sidebar - view channels you are following.", "Expand sidebar - view channels you are following.": "Expand sidebar - view channels you are following.",
"--end--": "--end--" "--end--": "--end--"

View file

@ -14,7 +14,7 @@ function ClaimEffectiveAmount(props: Props) {
return null; return null;
} }
return <CreditAmount amount={Number(claim.meta.effective_amount)} />; return <CreditAmount amount={Number(claim.meta.effective_amount || claim.amount)} />;
} }
export default ClaimEffectiveAmount; export default ClaimEffectiveAmount;

View file

@ -0,0 +1,30 @@
// @flow
import classnames from 'classnames';
import React from 'react';
type Props = {
isChannel: boolean,
type: string,
};
function ClaimPreviewLoading(props: Props) {
const { isChannel, type } = props;
return (
<li
className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
})}
>
<div className={classnames('claim-preview', { 'claim-preview--large': type === 'large' })}>
<div className="placeholder media__thumb" />
<div className="placeholder__wrapper">
<div className="placeholder claim-preview__title" />
<div className="placeholder media__subtitle" />
</div>
</div>
</li>
);
}
export default ClaimPreviewLoading;

View file

@ -0,0 +1,32 @@
// @flow
import classnames from 'classnames';
import React from 'react';
import Empty from 'component/common/empty';
type Props = {
isChannel: boolean,
type: string,
};
function ClaimPreviewNoContent(props: Props) {
const { isChannel, type } = props;
return (
<li
className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
})}
>
<div
className={classnames('claim-preview', {
'claim-preview--large': type === 'large',
'claim-preview__empty': true,
})}
>
<Empty text={__('Nothing found here. Like big tech ethics.')} />
</div>
</li>
);
}
export default ClaimPreviewNoContent;

View file

@ -0,0 +1,35 @@
// @flow
import classnames from 'classnames';
import React from 'react';
import Empty from 'component/common/empty';
type Props = {
isChannel: boolean,
type: string,
};
function ClaimPreviewNoMature(props: Props) {
const { isChannel, type } = props;
return (
<li
className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
})}
>
<div className={classnames('claim-preview', { 'claim-preview--large': type === 'large' })}>
<div className="media__thumb" />
<div
className={classnames('claim-preview', {
'claim-preview--large': type === 'large',
'claim-preview__empty': true,
})}
>
<Empty text={__('Mature content hidden by your preferences')} />
</div>
</div>
</li>
);
}
export default ClaimPreviewNoMature;

View file

@ -22,6 +22,9 @@ import ClaimRepostAuthor from 'component/claimRepostAuthor';
import FileDownloadLink from 'component/fileDownloadLink'; import FileDownloadLink from 'component/fileDownloadLink';
import AbandonedChannelPreview from 'component/abandonedChannelPreview'; import AbandonedChannelPreview from 'component/abandonedChannelPreview';
import PublishPending from 'component/publishPending'; import PublishPending from 'component/publishPending';
import ClaimPreviewLoading from './claim-preview-loading';
import ClaimPreviewNoMature from './claim-preview-no-mature';
import ClaimPreviewNoContent from './claim-preview-no-content';
type Props = { type Props = {
uri: string, uri: string,
@ -58,6 +61,7 @@ type Props = {
getFile: string => void, getFile: string => void,
customShouldHide?: Claim => boolean, customShouldHide?: Claim => boolean,
showUnresolvedClaim?: boolean, showUnresolvedClaim?: boolean,
showNullPlaceholder?: boolean,
includeSupportAction?: boolean, includeSupportAction?: boolean,
hideActions?: boolean, hideActions?: boolean,
renderActions?: Claim => ?Node, renderActions?: Claim => ?Node,
@ -94,6 +98,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
streamingUrl, streamingUrl,
customShouldHide, customShouldHide,
showUnresolvedClaim, showUnresolvedClaim,
showNullPlaceholder,
includeSupportAction, includeSupportAction,
hideActions = false, hideActions = false,
renderActions, renderActions,
@ -195,29 +200,22 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
} }
}, [isValid, isResolvingUri, uri, resolveUri, shouldFetch]); }, [isValid, isResolvingUri, uri, resolveUri, shouldFetch]);
if (shouldHide) { if (shouldHide && !showNullPlaceholder) {
return null; return null;
} }
if (placeholder === 'loading' || claim === undefined || (isResolvingUri && !claim)) { if (placeholder === 'loading' || (isResolvingUri && !claim)) {
return ( return <ClaimPreviewLoading isChannel={isChannel} type={type} />;
<li
disabled
className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
})}
>
<div className={classnames('claim-preview', { 'claim-preview--large': type === 'large' })}>
<div className="placeholder media__thumb" />
<div className="placeholder__wrapper">
<div className="placeholder claim-preview__title" />
<div className="placeholder media__subtitle" />
</div>
</div>
</li>
);
} }
if (claim && showNullPlaceholder && shouldHide && nsfw) {
return <ClaimPreviewNoMature isChannel={isChannel} type={type} />;
}
if (!claim && showNullPlaceholder) {
return <ClaimPreviewNoContent isChannel={isChannel} type={type} />;
}
if (!shouldFetch && showUnresolvedClaim && !isResolvingUri && claim === null) { if (!shouldFetch && showUnresolvedClaim && !isResolvingUri && claim === null) {
return <AbandonedChannelPreview uri={uri} type />; return <AbandonedChannelPreview uri={uri} type />;
} }

View file

@ -12,6 +12,7 @@ import CommentCreate from 'component/commentCreate';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import { ENABLE_COMMENT_REACTIONS } from 'config'; import { ENABLE_COMMENT_REACTIONS } from 'config';
import { sortComments } from 'util/comments'; import { sortComments } from 'util/comments';
import Empty from 'component/common/empty';
type Props = { type Props = {
comments: Array<Comment>, comments: Array<Comment>,
@ -58,7 +59,7 @@ function CommentList(props: Props) {
Boolean(reactionsById) || !ENABLE_COMMENT_REACTIONS Boolean(reactionsById) || !ENABLE_COMMENT_REACTIONS
); );
const linkedCommentId = linkedComment && linkedComment.comment_id; const linkedCommentId = linkedComment && linkedComment.comment_id;
const hasNoComments = totalComments === 0; const hasNoComments = !totalComments;
const moreBelow = totalComments - end > 0; const moreBelow = totalComments - end > 0;
const isMyComment = (channelId: string): boolean => { const isMyComment = (channelId: string): boolean => {
if (myChannels != null && channelId != null) { if (myChannels != null && channelId != null) {
@ -210,7 +211,7 @@ function CommentList(props: Props) {
<> <>
<CommentCreate uri={uri} /> <CommentCreate uri={uri} />
{!isFetchingComments && hasNoComments && <div className="main--empty">{__('Be the first to comment!')}</div>} {!isFetchingComments && hasNoComments && <Empty text={__('That was pretty deep. What do you think?')} />}
<ul className="comments" ref={commentRef}> <ul className="comments" ref={commentRef}>
{comments && {comments &&

View file

@ -0,0 +1,30 @@
// @flow
import React from 'react';
type Props = {
text: ?string,
};
class Empty extends React.PureComponent<Props> {
static defaultProps = {
text: '',
};
render() {
const { text } = this.props;
return (
<div className="empty__wrap">
<div>
{text && (
<div className="empty__content">
<p className="empty__text">{text}</p>
</div>
)}
</div>
</div>
);
}
}
export default Empty;

View file

@ -74,7 +74,7 @@ function FileActions(props: Props) {
push(`/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`); push(`/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`);
doToast({ message: __('A channel is required to repost on %SITE_NAME%', { SITE_NAME }) }); doToast({ message: __('A channel is required to repost on %SITE_NAME%', { SITE_NAME }) });
} else { } else {
openModal(MODALS.REPOST, { uri }); push(`/$/${PAGES.REPOST_NEW}?from=${encodeURIComponent(uri)}`);
} }
} }

View file

@ -205,7 +205,7 @@ const Header = (props: Props) => {
icon={ICONS.ARROW_LEFT} icon={ICONS.ARROW_LEFT}
/> />
{backTitle && <h1 className="header__auth-title">{isMobile ? simpleBackTitle || backTitle : backTitle}</h1>} {backTitle && <h1 className="header__auth-title">{isMobile ? simpleBackTitle || backTitle : backTitle}</h1>}
{authenticated ? ( {authenticated || !IS_WEB ? (
<Button <Button
aria-label={__('Your wallet')} aria-label={__('Your wallet')}
navigate={`/$/${PAGES.WALLET}`} navigate={`/$/${PAGES.WALLET}`}

View file

@ -0,0 +1,45 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import {
makeSelectClaimForUri,
makeSelectTitleForUri,
selectBalance,
selectMyChannelClaims,
doRepost,
selectRepostError,
selectRepostLoading,
doClearRepostError,
selectMyClaimsWithoutChannels,
doCheckPublishNameAvailability,
doCheckPendingClaims,
makeSelectEffectiveAmountForUri,
makeSelectIsUriResolving,
} from 'lbry-redux';
import { doToast } from 'redux/actions/notifications';
import RepostCreate from './view';
const select = (state, props) => ({
channels: selectMyChannelClaims(state),
claim: makeSelectClaimForUri(props.uri)(state),
passedRepostClaim: makeSelectClaimForUri(props.name)(state),
passedRepostAmount: makeSelectEffectiveAmountForUri(props.name)(state),
enteredContentClaim: makeSelectClaimForUri(props.contentUri)(state),
enteredRepostClaim: makeSelectClaimForUri(props.repostUri)(state),
enteredRepostAmount: makeSelectEffectiveAmountForUri(props.repostUri)(state),
title: makeSelectTitleForUri(props.uri)(state),
balance: selectBalance(state),
error: selectRepostError(state),
reposting: selectRepostLoading(state),
myClaims: selectMyClaimsWithoutChannels(state),
isResolvingPassedRepost: props.name && makeSelectIsUriResolving(`lbry://${props.name}`)(state),
isResolvingEnteredRepost: props.repostUri && makeSelectIsUriResolving(`lbry://${props.repostUri}`)(state),
});
export default connect(select, {
doHideModal,
doRepost,
doClearRepostError,
doToast,
doCheckPublishNameAvailability,
doCheckPendingClaims,
})(RepostCreate);

View file

@ -0,0 +1,416 @@
// @flow
import * as ICONS from 'constants/icons';
import { CHANNEL_NEW, MINIMUM_PUBLISH_BID, INVALID_NAME_ERROR } from 'constants/claim';
import React from 'react';
import { useHistory } from 'react-router';
import Card from 'component/common/card';
import Button from 'component/button';
import SelectChannel from 'component/selectChannel';
import { FormField } from 'component/common/form';
import { parseURI, isNameValid, creditsToString, isURIValid, normalizeURI } from 'lbry-redux';
import usePersistedState from 'effects/use-persisted-state';
import analytics from 'analytics';
import LbcSymbol from 'component/common/lbc-symbol';
import ClaimPreview from 'component/claimPreview';
import { URL as SITE_URL, URL_LOCAL, URL_DEV } from 'config';
import HelpLink from 'component/common/help-link';
type Props = {
doToast: ({ message: string }) => void,
doClearRepostError: () => void,
doRepost: StreamRepostOptions => Promise<*>,
title: string, //
claim?: StreamClaim,
enteredContentClaim?: StreamClaim,
balance: number,
channels: ?Array<ChannelClaim>,
doCheckPublishNameAvailability: string => Promise<*>,
error: ?string,
reposting: boolean,
uri: string,
name: string,
contentUri: string,
setRepostUri: string => void,
setContentUri: string => void,
doCheckPendingClaims: () => void,
redirectUri?: string,
passedRepostAmount: number,
enteredRepostAmount: number,
isResolvingPassedRepost: boolean,
isResolvingEnteredRepost: boolean,
};
function RepostCreate(props: Props) {
const {
redirectUri,
doToast,
doClearRepostError,
doRepost,
claim,
enteredContentClaim,
balance,
channels,
reposting,
doCheckPublishNameAvailability,
uri, // ?from
name, // ?to
contentUri,
setRepostUri,
setContentUri,
doCheckPendingClaims,
enteredRepostAmount,
passedRepostAmount,
isResolvingPassedRepost,
isResolvingEnteredRepost,
} = props;
const defaultName = name || (claim && claim.name) || '';
const contentClaimId = claim && claim.claim_id;
const enteredClaimId = enteredContentClaim && enteredContentClaim.claim_id;
const [repostChannel, setRepostChannel] = usePersistedState('repost-channel', 'anonymous');
const [repostBid, setRepostBid] = React.useState(0.01);
const [repostBidError, setRepostBidError] = React.useState(undefined);
// const [bidChanged, setBidChanged] = React.useState(false);
const [takeoverAmount, setTakeoverAmount] = React.useState(0);
const [enteredRepostName, setEnteredRepostName] = React.useState(defaultName);
const [available, setAvailable] = React.useState(true);
const [enteredContent, setEnteredContentUri] = React.useState(undefined);
const [contentError, setContentError] = React.useState('');
const { replace, goBack } = useHistory();
const resolvingRepost = isResolvingEnteredRepost || isResolvingPassedRepost;
const repostUrlName = `lbry://${
!repostChannel || repostChannel === CHANNEL_NEW || repostChannel === 'anonymous' ? '' : `${repostChannel}/`
}`;
const contentFirstRender = React.useRef(true);
const setAutoRepostBid = amount => {
if (balance > amount) {
if (amount > 5) {
setRepostBid(Number(amount.toFixed(2)));
} else {
setRepostBid(5);
}
} else if (balance) {
setRepostBid(0.01);
}
};
function getSearchUri(value) {
const WEB_DEV_PREFIX = `${URL_DEV}/`;
const WEB_LOCAL_PREFIX = `${URL_LOCAL}/`;
const WEB_PROD_PREFIX = `${SITE_URL}/`;
const ODYSEE_PREFIX = `https://odysee.com/`;
const includesLbryTvProd = value.includes(WEB_PROD_PREFIX);
const includesOdysee = value.includes(ODYSEE_PREFIX);
const includesLbryTvLocal = value.includes(WEB_LOCAL_PREFIX);
const includesLbryTvDev = value.includes(WEB_DEV_PREFIX);
const wasCopiedFromWeb = includesLbryTvDev || includesLbryTvLocal || includesLbryTvProd || includesOdysee;
const isLbryUrl = value.startsWith('lbry://') && value !== 'lbry://';
const error = '';
const addLbryIfNot = term => {
return term.startsWith('lbry://') ? term : `lbry://${term}`;
};
if (wasCopiedFromWeb) {
let prefix = WEB_PROD_PREFIX;
if (includesLbryTvLocal) prefix = WEB_LOCAL_PREFIX;
if (includesLbryTvDev) prefix = WEB_DEV_PREFIX;
if (includesOdysee) prefix = ODYSEE_PREFIX;
let query = (value && value.slice(prefix.length).replace(/:/g, '#')) || '';
try {
const lbryUrl = `lbry://${query}`;
parseURI(lbryUrl);
return [lbryUrl, null];
} catch (e) {
return [query, 'error'];
}
}
if (!isLbryUrl) {
if (value.startsWith('@')) {
if (isNameValid(value.slice(1))) {
return [value, null];
} else {
return [value, error];
}
}
return [addLbryIfNot(value), null];
} else {
try {
const isValid = isURIValid(value);
if (isValid) {
let uri;
try {
uri = normalizeURI(value);
} catch (e) {
return [value, null];
}
return [uri, null];
} else {
return [value, null];
}
} catch (e) {
return [value, 'error'];
}
}
}
// repostName
let repostNameError;
if (!enteredRepostName) {
repostNameError = __('A name is required');
} else if (!isNameValid(enteredRepostName, false)) {
repostNameError = INVALID_NAME_ERROR;
} else if (!available) {
repostNameError = __('You already have a claim with this name.');
}
// contentName
let contentNameError;
if (!enteredContent && enteredContent !== undefined) {
contentNameError = __('A name is required');
}
// repostChannel
const channelStrings = channels && channels.map(channel => channel.permanent_url).join(',');
React.useEffect(() => {
if (!repostChannel && channelStrings) {
const channels = channelStrings.split(',');
const newChannelUrl = channels[0];
const { claimName } = parseURI(newChannelUrl);
setRepostChannel(claimName);
}
}, [channelStrings]);
React.useEffect(() => {
if (enteredRepostName && isNameValid(enteredRepostName, false)) {
doCheckPublishNameAvailability(enteredRepostName).then(r => setAvailable(r));
}
}, [enteredRepostName, doCheckPublishNameAvailability]);
// takeover amount, bid suggestion
React.useEffect(() => {
const repostTakeoverAmount = Number(enteredRepostAmount)
? Number(enteredRepostAmount) + 0.01
: Number(passedRepostAmount) + 0.01;
if (repostTakeoverAmount) {
setTakeoverAmount(Number(repostTakeoverAmount.toFixed(2)));
setAutoRepostBid(repostTakeoverAmount);
}
}, [setTakeoverAmount, enteredRepostAmount, passedRepostAmount]);
// repost bid error
React.useEffect(() => {
let rBidError;
if (repostBid === 0) {
rBidError = __('Deposit cannot be 0');
} else if (balance === repostBid) {
rBidError = __('Please decrease your deposit to account for transaction fees');
} else if (balance < repostBid) {
rBidError = __('Deposit cannot be higher than your balance');
} else if (repostBid < MINIMUM_PUBLISH_BID) {
rBidError = __('Your deposit must be higher');
}
setRepostBidError(rBidError);
}, [setRepostBidError, repostBidError, repostBid]);
// setContentUri given enteredUri
React.useEffect(() => {
if (!enteredContent && !contentFirstRender.current) {
setContentError(__('A name is required'));
}
if (enteredContent) {
contentFirstRender.current = false;
const [searchContent, error] = getSearchUri(enteredContent);
if (error) {
setContentError(__('Something not quite right..'));
} else {
setContentError('');
}
try {
const { streamName, channelName, isChannel } = parseURI(searchContent);
if (!isChannel && streamName && isNameValid(streamName)) {
// contentNameValid = true;
setContentUri(searchContent);
} else if (isChannel && channelName && isNameValid(channelName)) {
// contentNameValid = true;
setContentUri(searchContent);
}
} catch (e) {
if (enteredContent !== '@') setContentError('');
setContentUri(``);
}
}
}, [enteredContent, setContentUri, setContentError, parseURI, isNameValid]);
// setRepostName
React.useEffect(() => {
if (enteredRepostName) {
let isValid = false;
try {
parseURI(enteredRepostName);
isValid = true;
} catch (e) {
isValid = false;
}
if (isValid) {
setRepostUri(enteredRepostName);
}
}
}, [enteredRepostName, setRepostUri, parseURI]);
const repostClaimId = contentClaimId || enteredClaimId;
const getRedirect = (entered, passed, redirect) => {
if (redirect) {
return redirect;
} else if (entered) {
try {
const { claimName } = parseURI(entered);
return `/${claimName}`;
} catch (e) {
return '/';
}
} else if (passed) {
try {
const { claimName } = parseURI(passed);
return `/${claimName}`;
} catch (e) {
return '/';
}
} else {
return '/';
}
};
function handleSubmit() {
const channelToRepostTo = channels && channels.find(channel => channel.name === repostChannel);
if (enteredRepostName && repostBid && repostClaimId) {
doRepost({
name: enteredRepostName,
bid: creditsToString(repostBid),
channel_id: channelToRepostTo ? channelToRepostTo.claim_id : undefined,
claim_id: repostClaimId,
}).then((repostClaim: StreamClaim) => {
doCheckPendingClaims();
analytics.apiLogPublish(repostClaim);
doToast({ message: __('Woohoo! Successfully reposted this claim.') });
replace(getRedirect(contentUri, uri, redirectUri));
});
}
}
function cancelIt() {
doClearRepostError();
goBack();
}
return (
<>
<Card
actions={
<div>
{uri && (
<fieldset-section>
<ClaimPreview key={uri} uri={uri} actions={''} type={'inline'} showNullPlaceholder />
</fieldset-section>
)}
{!uri && name && (
<>
<FormField
label={'Content to repost'}
type="text"
name="content_url"
value={enteredContent}
error={contentError}
onChange={event => setEnteredContentUri(event.target.value)}
placeholder={__('Enter a name or %domain% URL', { domain: SITE_URL })}
/>
</>
)}
{!uri && (
<fieldset-section>
<ClaimPreview key={contentUri} uri={contentUri} actions={''} type={'large'} showNullPlaceholder />
</fieldset-section>
)}
<React.Fragment>
<fieldset-section>
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="auth_first_channel">
{repostNameError ? (
<span className="error__text">{repostNameError}</span>
) : (
<span>
{__('Repost URL')}
<HelpLink href="https://lbry.com/faq/naming" />
</span>
)}
</label>
<div className="form-field__prefix">{repostUrlName}</div>
</fieldset-section>
<FormField
type="text"
name="repost_name"
value={enteredRepostName}
onChange={event => setEnteredRepostName(event.target.value)}
placeholder={__('Do a thing')}
/>
</fieldset-group>
</fieldset-section>
<SelectChannel
label={__('Channel to repost on')}
hideNew
channel={repostChannel}
onChannelChange={newChannel => setRepostChannel(newChannel)}
/>
<FormField
type="number"
name="repost_bid"
min="0"
step="any"
placeholder="0.123"
className="form-field--price-amount"
label={<LbcSymbol postfix={__('Support')} size={14} />}
value={repostBid}
error={repostBidError}
helper={__('Winning amount: %amount%', {
amount: Number(takeoverAmount).toFixed(2),
})}
disabled={!enteredRepostName || resolvingRepost}
onChange={event => setRepostBid(event.target.value)}
onWheel={e => e.stopPropagation()}
/>
</React.Fragment>
<div className="section__actions">
<Button
icon={ICONS.REPOST}
disabled={
resolvingRepost ||
reposting ||
repostBidError ||
repostNameError ||
((!uri || enteredContent) && contentNameError) ||
(!uri && !enteredContentClaim)
}
button="primary"
label={reposting ? __('Reposting') : __('Repost')}
onClick={handleSubmit}
/>
<Button button="link" label={__('Cancel')} onClick={cancelIt} />
</div>
</div>
}
/>
</>
);
}
export default RepostCreate;

View file

@ -43,6 +43,7 @@ import CreatorDashboard from 'page/creatorDashboard';
import RewardsVerifyPage from 'page/rewardsVerify'; import RewardsVerifyPage from 'page/rewardsVerify';
import CheckoutPage from 'page/checkoutPage'; import CheckoutPage from 'page/checkoutPage';
import ChannelNew from 'page/channelNew'; import ChannelNew from 'page/channelNew';
import RepostNew from 'page/repost';
import BuyPage from 'page/buy'; import BuyPage from 'page/buy';
import NotificationsPage from 'page/notifications'; import NotificationsPage from 'page/notifications';
import SignInWalletPasswordPage from 'page/signInWalletPassword'; import SignInWalletPasswordPage from 'page/signInWalletPassword';
@ -262,6 +263,7 @@ function AppRouter(props: Props) {
/> />
<PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} /> <PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} /> <PrivateRoute {...props} path={`/$/${PAGES.CHANNEL_NEW}`} component={ChannelNew} />
<PrivateRoute {...props} path={`/$/${PAGES.REPOST_NEW}`} component={RepostNew} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOADS}`} component={FileListPublished} /> <PrivateRoute {...props} path={`/$/${PAGES.UPLOADS}`} component={FileListPublished} />
<PrivateRoute {...props} path={`/$/${PAGES.CREATOR_DASHBOARD}`} component={CreatorDashboard} /> <PrivateRoute {...props} path={`/$/${PAGES.CREATOR_DASHBOARD}`} component={CreatorDashboard} />
<PrivateRoute {...props} path={`/$/${PAGES.UPLOAD}`} component={PublishPage} /> <PrivateRoute {...props} path={`/$/${PAGES.UPLOAD}`} component={PublishPage} />

View file

@ -1,12 +1,22 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doResolveUris } from 'lbry-redux'; import { doResolveUris, doClearPublish, doPrepareEdit, selectPendingIds } from 'lbry-redux';
import { makeSelectWinningUriForQuery } from 'redux/selectors/search'; import { makeSelectWinningUriForQuery } from 'redux/selectors/search';
import SearchTopClaim from './view'; import SearchTopClaim from './view';
import { push } from 'connected-react-router';
import * as PAGES from 'constants/pages';
const select = (state, props) => ({ const select = (state, props) => ({
winningUri: makeSelectWinningUriForQuery(props.query)(state), winningUri: makeSelectWinningUriForQuery(props.query)(state),
pendingIds: selectPendingIds(state),
}); });
export default connect(select, { const perform = dispatch => ({
doResolveUris, beginPublish: name => {
})(SearchTopClaim); dispatch(doClearPublish());
dispatch(doPrepareEdit({ name }));
dispatch(push(`/$/${PAGES.UPLOAD}`));
},
doResolveUris: uris => dispatch(doResolveUris(uris)),
});
export default connect(select, perform)(SearchTopClaim);

View file

@ -6,8 +6,11 @@ import { parseURI } from 'lbry-redux';
import ClaimPreview from 'component/claimPreview'; import ClaimPreview from 'component/claimPreview';
import Button from 'component/button'; import Button from 'component/button';
import ClaimEffectiveAmount from 'component/claimEffectiveAmount'; import ClaimEffectiveAmount from 'component/claimEffectiveAmount';
import HelpLink from 'component/common/help-link';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import { useHistory } from 'react-router';
import LbcSymbol from 'component/common/lbc-symbol';
import { DOMAIN } from 'config';
import Yrbl from 'component/yrbl';
type Props = { type Props = {
query: string, query: string,
@ -15,20 +18,26 @@ type Props = {
doResolveUris: (Array<string>) => void, doResolveUris: (Array<string>) => void,
hideLink?: boolean, hideLink?: boolean,
setChannelActive: boolean => void, setChannelActive: boolean => void,
beginPublish: string => void,
pendingIds: Array<string>,
}; };
export default function SearchTopClaim(props: Props) { export default function SearchTopClaim(props: Props) {
const { doResolveUris, query = '', winningUri, hideLink = false, setChannelActive } = props; const { doResolveUris, query = '', winningUri, hideLink = false, setChannelActive, beginPublish } = props;
const uriFromQuery = `lbry://${query}`; const uriFromQuery = `lbry://${query}`;
const { push } = useHistory();
let name;
let channelUriFromQuery; let channelUriFromQuery;
let winningUriIsChannel; let winningUriIsChannel;
try { try {
const { isChannel } = parseURI(uriFromQuery); const { isChannel, streamName, channelName } = parseURI(uriFromQuery);
const { isChannel: winnerIsChannel } = parseURI(winningUri); const { isChannel: winnerIsChannel } = parseURI(winningUri);
winningUriIsChannel = winnerIsChannel; winningUriIsChannel = winnerIsChannel;
if (!isChannel) { if (!isChannel) {
channelUriFromQuery = `lbry://@${query}`; channelUriFromQuery = `lbry://@${query}`;
name = streamName;
} else {
name = channelName;
} }
} catch (e) {} } catch (e) {}
@ -51,33 +60,68 @@ export default function SearchTopClaim(props: Props) {
} }
}, [doResolveUris, uriFromQuery, channelUriFromQuery]); }, [doResolveUris, uriFromQuery, channelUriFromQuery]);
if (!winningUri) {
return null;
}
return ( return (
<section className="search"> <section className="search">
<header className="search__header"> <header className="search__header">
<div className="claim-preview__actions--header"> {winningUri && (
<span className="media__uri"> <div className="claim-preview__actions--header">
{__('Most supported')} <a
<HelpLink href="https://lbry.com/faq/tipping" /> className="media__uri"
</span> href="https://lbry.com/faq/trending"
</div> title={__('Learn more about LBRY Credits on %DOMAIN%', { DOMAIN })}
<div className="card"> >
<ClaimPreview <I18nMessage
hideRepostLabel tokens={{
uri={winningUri} lbc: <LbcSymbol />,
type="large" }}
placeholder="publish" >
properties={claim => ( Most supported %lbc%
<span className="claim-preview__custom-properties"> </I18nMessage>
<ClaimEffectiveAmount uri={winningUri} /> </a>
</span> </div>
)} )}
/> {winningUri && (
</div> <div className="card">
{!hideLink && ( <ClaimPreview
hideRepostLabel
uri={winningUri}
type="large"
placeholder="publish"
properties={claim => (
<span className="claim-preview__custom-properties">
<ClaimEffectiveAmount uri={winningUri} />
</span>
)}
/>
</div>
)}
{!winningUri && uriFromQuery && (
<div className="empty empty--centered">
<Yrbl
type="happy"
title={__('Whoa!')}
small
subtitle={
<I18nMessage
tokens={{
repost: (
<Button
button="link"
onClick={() => push(`/$/${PAGES.REPOST_NEW}?to=${name}`)}
label={__('Repost')}
/>
),
publish: <Button button="link" onClick={() => beginPublish(name)} label={'publish'} />,
name: <strong>name</strong>,
}}
>
You have found the edge of the internet. %repost% or %publish% your stuff here to claim this spot.
</I18nMessage>
}
/>
</div>
)}
{!hideLink && winningUri && (
<div className="section__actions--between section__actions--no-margin"> <div className="section__actions--between section__actions--no-margin">
<span /> <span />
<Button <Button

View file

@ -11,6 +11,7 @@ type Props = {
className?: string, className?: string,
actions?: Node, actions?: Node,
alwaysShow?: boolean, alwaysShow?: boolean,
small: boolean,
}; };
const yrblTypes = { const yrblTypes = {
@ -24,7 +25,7 @@ export default class extends React.PureComponent<Props> {
}; };
render() { render() {
const { title, subtitle, type, className, actions, alwaysShow = false } = this.props; const { title, subtitle, type, className, actions, small, alwaysShow = false } = this.props;
const image = yrblTypes[type]; const image = yrblTypes[type];
@ -32,7 +33,7 @@ export default class extends React.PureComponent<Props> {
<div className="yrbl__wrap"> <div className="yrbl__wrap">
<img <img
alt="Friendly gerbil" alt="Friendly gerbil"
className={classnames('yrbl', className, { className={classnames(small ? 'yrbl--small' : 'yrbl', className, {
'yrbl--always-show': alwaysShow, 'yrbl--always-show': alwaysShow,
})} })}
src={`${image}`} src={`${image}`}

View file

@ -19,6 +19,7 @@ exports.GET_CREDITS = 'getcredits';
exports.REPORT = 'report'; exports.REPORT = 'report';
exports.REWARDS = 'rewards'; exports.REWARDS = 'rewards';
exports.REWARDS_VERIFY = 'rewards/verify'; exports.REWARDS_VERIFY = 'rewards/verify';
exports.REPOST_NEW = 'repost';
exports.SEND = 'send'; exports.SEND = 'send';
exports.SETTINGS = 'settings'; exports.SETTINGS = 'settings';
exports.SETTINGS_NOTIFICATIONS = 'settings/notifications'; exports.SETTINGS_NOTIFICATIONS = 'settings/notifications';

View file

@ -12,7 +12,7 @@ import { parseURI, isNameValid, creditsToString } from 'lbry-redux';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import analytics from 'analytics'; import analytics from 'analytics';
import LbcSymbol from '../../component/common/lbc-symbol'; import LbcSymbol from 'component/common/lbc-symbol';
type Props = { type Props = {
doHideModal: () => void, doHideModal: () => void,

View file

@ -6,7 +6,6 @@ import YrblWalletEmpty from 'component/yrblWalletEmpty';
type Props = { type Props = {
balance: number, balance: number,
totalRewardValue: number,
}; };
function PublishPage(props: Props) { function PublishPage(props: Props) {

15
ui/page/repost/index.js Normal file
View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { doResolveUri, selectBalance } from 'lbry-redux';
import RepostPage from './view';
const select = (state, props) => ({
balance: selectBalance(state),
});
const perform = dispatch => ({
resolveUri: uri => dispatch(doResolveUri(uri)),
});
export default connect(select, perform)(RepostPage);

77
ui/page/repost/view.jsx Normal file
View file

@ -0,0 +1,77 @@
// @flow
import React from 'react';
import Page from 'component/page';
import { useHistory } from 'react-router';
import RepostCreate from 'component/repostCreate';
import YrblWalletEmpty from 'component/yrblWalletEmpty';
import useThrottle from 'effects/use-throttle';
import classnames from 'classnames';
type Props = {
balance: number,
resolveUri: string => void,
};
function RepostPage(props: Props) {
const { balance, resolveUri } = props;
const REPOST_FROM = 'from';
const REPOST_TO = 'to';
const REDIRECT = 'redirect';
const {
location: { search },
} = useHistory();
const urlParams = new URLSearchParams(search);
const repostFrom = urlParams.get(REPOST_FROM);
const redirectUri = urlParams.get(REDIRECT);
const repostTo = urlParams.get(REPOST_TO);
const [contentUri, setContentUri] = React.useState('');
const [repostUri, setRepostUri] = React.useState('');
const throttledContentValue = useThrottle(contentUri, 500);
const throttledRepostValue = useThrottle(repostUri, 500);
React.useEffect(() => {
if (throttledContentValue) {
resolveUri(throttledContentValue);
}
}, [throttledContentValue, resolveUri]);
React.useEffect(() => {
if (throttledRepostValue) {
resolveUri(throttledRepostValue);
}
}, [throttledRepostValue, resolveUri]);
React.useEffect(() => {
if (repostTo) {
resolveUri(repostTo);
}
}, [repostTo, resolveUri]);
const decodedFrom = repostFrom && decodeURIComponent(repostFrom);
return (
<Page
noFooter
noSideNavigation
backout={{
title: __('Repost'),
backLabel: __('Back'),
}}
>
{balance === 0 && <YrblWalletEmpty />}
<div className={classnames({ 'card--disabled': balance === 0 })}>
<RepostCreate
uri={decodedFrom}
name={repostTo}
redirectUri={redirectUri}
repostUri={repostUri}
contentUri={contentUri}
setContentUri={setContentUri}
setRepostUri={setRepostUri}
/>
</div>
</Page>
);
}
export default RepostPage;

View file

@ -11,6 +11,7 @@ import Ads from 'web/component/ads';
import SearchTopClaim from 'component/searchTopClaim'; import SearchTopClaim from 'component/searchTopClaim';
import { formatLbryUrlForWeb } from 'util/url'; import { formatLbryUrlForWeb } from 'util/url';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import ClaimPreview from 'component/claimPreview';
type AdditionalOptions = { type AdditionalOptions = {
isBackgroundSearch: boolean, isBackgroundSearch: boolean,
@ -97,7 +98,6 @@ export default function SearchPage(props: Props) {
{urlQuery && ( {urlQuery && (
<> <>
{isValid && <SearchTopClaim query={modifiedUrlQuery} />} {isValid && <SearchTopClaim query={modifiedUrlQuery} />}
<ClaimList <ClaimList
uris={uris} uris={uris}
loading={isSearching} loading={isSearching}
@ -121,6 +121,7 @@ export default function SearchPage(props: Props) {
</> </>
} }
/> />
{isSearching && new Array(5).fill(1).map((x, i) => <ClaimPreview key={i} placeholder="loading" />)}
<div className="main--empty help">{__('These search results are provided by LBRY, Inc.')}</div> <div className="main--empty help">{__('These search results are provided by LBRY, Inc.')}</div>
</> </>

View file

@ -1,5 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import TopPage from './view'; import TopPage from './view';
import { doClearPublish, doPrepareEdit, doResolveUris } from 'lbry-redux';
import { push } from 'connected-react-router';
import * as PAGES from 'constants/pages';
const select = (state, props) => { const select = (state, props) => {
const { search } = props.location; const { search } = props.location;
@ -11,4 +14,13 @@ const select = (state, props) => {
}; };
}; };
export default connect(select)(TopPage); const perform = dispatch => ({
beginPublish: name => {
dispatch(doClearPublish());
dispatch(doPrepareEdit({ name }));
dispatch(push(`/$/${PAGES.UPLOAD}`));
},
doResolveUris: uris => dispatch(doResolveUris(uris)),
});
export default connect(select, perform)(TopPage);

View file

@ -7,22 +7,42 @@ import ClaimEffectiveAmount from 'component/claimEffectiveAmount';
import SearchTopClaim from 'component/searchTopClaim'; import SearchTopClaim from 'component/searchTopClaim';
import { ORDER_BY_TOP, FRESH_ALL } from 'constants/claim_search'; import { ORDER_BY_TOP, FRESH_ALL } from 'constants/claim_search';
import Button from 'component/button'; import Button from 'component/button';
import I18nMessage from 'component/i18nMessage';
import * as PAGES from 'constants/pages';
type Props = { type Props = {
name: string, name: string,
beginPublish: string => void,
}; };
function TopPage(props: Props) { function TopPage(props: Props) {
const { name } = props; const { name, beginPublish } = props;
const [channelActive, setChannelActive] = React.useState(false); const [channelActive, setChannelActive] = React.useState(false);
// if the query was actually '@name', still offer repost for 'name'
const queryName = name[0] === '@' ? name.slice(1) : name;
return ( return (
<Page> <Page>
<SearchTopClaim query={name} hideLink setChannelActive={setChannelActive} /> <SearchTopClaim query={name} hideLink setChannelActive={setChannelActive} />
<ClaimListDiscover <ClaimListDiscover
name={channelActive ? `@${name}` : name} name={channelActive ? `@${queryName}` : queryName}
defaultFreshness={FRESH_ALL} defaultFreshness={FRESH_ALL}
defaultOrderBy={ORDER_BY_TOP} defaultOrderBy={ORDER_BY_TOP}
meta={
<I18nMessage
tokens={{
repost: (
<Button
button="secondary"
navigate={`/$/${PAGES.REPOST_NEW}?to=${queryName}`}
label={__('Repost Here')}
/>
),
publish: <Button button="secondary" onClick={() => beginPublish(queryName)} label={'Publish Here'} />,
}}
>
%repost% %publish%
</I18nMessage>
}
includeSupportAction includeSupportAction
renderProperties={claim => ( renderProperties={claim => (
<span className="claim-preview__custom-properties"> <span className="claim-preview__custom-properties">
@ -33,7 +53,7 @@ function TopPage(props: Props) {
header={ header={
<div className="claim-search__menu-group"> <div className="claim-search__menu-group">
<Button <Button
label={name} label={queryName}
button="alt" button="alt"
onClick={() => setChannelActive(false)} onClick={() => setChannelActive(false)}
className={classnames('button-toggle', { className={classnames('button-toggle', {
@ -41,7 +61,7 @@ function TopPage(props: Props) {
})} })}
/> />
<Button <Button
label={`@${name}`} label={`@${queryName}`}
button="alt" button="alt"
onClick={() => setChannelActive(true)} onClick={() => setChannelActive(true)}
className={classnames('button-toggle', { className={classnames('button-toggle', {

View file

@ -1,7 +1,15 @@
// @flow // @flow
import { getSearchQueryString } from 'util/query-params'; import { getSearchQueryString } from 'util/query-params';
import { makeSelectClientSetting } from 'redux/selectors/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings';
import { parseURI, makeSelectClaimForUri, makeSelectClaimIsNsfw, buildURI, SETTINGS, isClaimNsfw } from 'lbry-redux'; import {
parseURI,
makeSelectClaimForUri,
makeSelectClaimIsNsfw,
buildURI,
SETTINGS,
isClaimNsfw,
makeSelectPendingClaimForUri,
} from 'lbry-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
type State = { search: SearchState }; type State = { search: SearchState };
@ -79,7 +87,6 @@ export const makeSelectRecommendedContentForUri = (uri: string) =>
recommendedContent = searchUris; recommendedContent = searchUris;
} }
} }
return recommendedContent; return recommendedContent;
} }
); );
@ -97,11 +104,13 @@ export const makeSelectWinningUriForQuery = (query: string) => {
return createSelector( return createSelector(
makeSelectClientSetting(SETTINGS.SHOW_MATURE), makeSelectClientSetting(SETTINGS.SHOW_MATURE),
makeSelectPendingClaimForUri(uriFromQuery),
makeSelectClaimForUri(uriFromQuery), makeSelectClaimForUri(uriFromQuery),
makeSelectClaimForUri(channelUriFromQuery), makeSelectClaimForUri(channelUriFromQuery),
(matureEnabled, claim1, claim2) => { (matureEnabled, pendingClaim, claim1, claim2) => {
const claim1Mature = claim1 && isClaimNsfw(claim1); const claim1Mature = claim1 && isClaimNsfw(claim1);
const claim2Mature = claim2 && isClaimNsfw(claim2); const claim2Mature = claim2 && isClaimNsfw(claim2);
let pendingAmount = pendingClaim && pendingClaim.amount;
if (!claim1 && !claim2) { if (!claim1 && !claim2) {
return undefined; return undefined;
@ -124,7 +133,13 @@ export const makeSelectWinningUriForQuery = (query: string) => {
} }
} }
return Number(effectiveAmount1) > Number(effectiveAmount2) ? claim1.canonical_url : claim2.canonical_url; const returnBeforePending =
Number(effectiveAmount1) > Number(effectiveAmount2) ? claim1.canonical_url : claim2.canonical_url;
if (pendingAmount && pendingAmount > effectiveAmount1 && pendingAmount > effectiveAmount2) {
return pendingAmount.permanent_url;
} else {
return returnBeforePending;
}
} }
); );
}; };

View file

@ -58,3 +58,4 @@
@import 'component/tags'; @import 'component/tags';
@import 'component/wunderbar'; @import 'component/wunderbar';
@import 'component/yrbl'; @import 'component/yrbl';
@import 'component/empty';

View file

@ -98,6 +98,12 @@
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
} }
.media__thumb-placeholder-text {
display: flex;
align-items: center;
padding: var(--spacing-m);
}
.channel-thumbnail { .channel-thumbnail {
@include handleChannelGif(6rem); @include handleChannelGif(6rem);
} }
@ -115,29 +121,47 @@
font-size: 14px; font-size: 14px;
.channel-thumbnail { .channel-thumbnail {
@include handleChannelGif(5rem); @include handleChannelGif(4rem);
} }
} }
} }
.claim-preview__empty {
display: flex;
align-items: center;
justify-content: center;
}
.claim-preview--large { .claim-preview--large {
border: none; border: none;
min-height: 8rem;
&:hover { &:hover {
background-color: transparent; background-color: transparent;
} }
@media (max-width: $breakpoint-small) {
min-height: 4rem;
}
.media__thumb { .media__thumb {
width: 14rem; width: 14rem;
@include handleClaimListGifThumbnail(14rem); @include handleClaimListGifThumbnail(14rem);
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
width: 5rem; min-height: 5rem;
width: 8rem;
} }
} }
.channel-thumbnail { .channel-thumbnail {
width: 7.5rem;
@include handleChannelGif(7.5rem); @include handleChannelGif(7.5rem);
@media (max-width: $breakpoint-small) {
width: 5rem;
@include handleChannelGif(5rem);
}
} }
} }
@ -201,6 +225,9 @@
.claim-preview__custom-properties { .claim-preview__custom-properties {
text-align: right; text-align: right;
display: flex;
flex-direction: column;
justify-content: flex-end;
} }
.claim-preview-metadata { .claim-preview-metadata {
@ -523,3 +550,7 @@
.claim-link { .claim-link {
position: relative; position: relative;
} }
.claim-preview__null-label {
margin: auto;
}

View file

@ -97,3 +97,15 @@
} }
} }
} }
.claim-search__menu-group--between {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
width: 100%;
&:last-of-type {
.button-toggle:last-of-type {
margin-right: 0;
}
}
}

View file

@ -0,0 +1,21 @@
.empty__wrap {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: wrap;
justify-content: center;
text-align: left;
@media (min-width: $breakpoint-small) {
flex-direction: row;
}
}
.empty__text {
color: var(--color-text-empty);
font-style: italic;
}
.empty__content {
max-width: 400px;
}

View file

@ -34,6 +34,16 @@
display: block; display: block;
} }
.yrbl--small {
display: none;
@media (min-width: $breakpoint-small) {
display: block;
height: 8rem;
margin-right: calc(var(--spacing-xl));
}
}
.yrbl__content { .yrbl__content {
max-width: 400px; max-width: 400px;
} }

View file

@ -64,7 +64,8 @@
--color-text: #d8d8d8; --color-text: #d8d8d8;
--color-text-error: #f5748c; --color-text-error: #f5748c;
--color-text-help-warning: #f5ec74; --color-text-help-warning: #f5ec74;
--color-text-empty: #bbbbbb; --color-text-empty: var(--color-text-subtitle);
--color-text-help: #bbbbbb; --color-text-help: #bbbbbb;
--color-text-subtitle: #9fafc0; --color-text-subtitle: #9fafc0;
--color-text-warning: #212529; --color-text-warning: #212529;
@ -74,7 +75,7 @@
// Input // Input
--color-input: #f4f4f5; --color-input: #f4f4f5;
--color-input-label: #d4d4d4; --color-input-label: #a7a7a7;
--color-input-placeholder: #f4f4f5; --color-input-placeholder: #f4f4f5;
--color-input-bg: #464d53; --color-input-bg: #464d53;
--color-input-bg-copyable: #4f5861; --color-input-bg-copyable: #4f5861;