wip

wip - everything but publish, autoplay, and styling

collection publishing

add channel to collection publish

cleanup

wip

bump

clear mass add after success

move collection item management controls

redirect replace to published collection id

bump

playlist selector on create

bump

use new collection add ui element

bump

wip

gitignore

add content json

wip

bump

context add to playlist

basic collections page style pass wip

wip: edits, buttons, styles...

change fileAuthor to claimAuthor

update, pending bugfixes, delete modal progress, collection header, other bugfixes

bump

cleaning

show page bugfix

builtin collection headers

no playlists, no grid title

wip

style tweaks

use normal looking claim previews for collection tiles

add collection changes

style library previews

collection menulist for delete/view on library

delete modal works for unpublished

rearrange collection publish tabs

clean up collection publishing and items

show on odysee

begin collectoin edit header and css renaming

better thumbnails

bump

fix collection publish redirect

view collection in menu does something

copy and thumbs

list previews, pending, context menus, list page

enter to add collection, lists page empty state

playable lists only, delete feature, bump

put fileListDownloaded back

better collection titles

improve collection claim details

fix horiz more icon

fix up channel page

style, copy, bump

refactor preview overlay properties,
fix reposts showing as floppydisk
add watch later toast,
small overlay properties on wunderbar results,
fix collection actions buttons

bump

cleanup

cleaning, refactoring

bump

preview thumb styling, cleanup

support discover page lists search

sync, bump

bump, fix sync more

enforce builtin order for now

new lists page empty state

try to indicate unpublished edits in lists

bump

fix autoplay and linting

consts, fix autoplay

bugs

fixes

cleanup

fix, bump

lists experimental ui, fixes

refactor listIndex out

hack in collection fallback thumb

bump
This commit is contained in:
zeppi 2021-02-06 02:03:51 -05:00 committed by jessopb
parent 46d258c439
commit ca116ba010
111 changed files with 3504 additions and 336 deletions

4
.gitignore vendored
View file

@ -23,8 +23,12 @@ package-lock.json
/web/.env.defaults
/custom/*
/custom/homepages/*
/custom/content/*
!/custom/content/default.json
!/custom/content/test.json
!/custom/homepages/.gitkeep
!/custom/homepages
!/custom/content
!/custom/homepage.example.js
!/custom/robots.disallowall
!/custom/robots.allowall

View file

@ -143,7 +143,7 @@
"imagesloaded": "^4.1.4",
"json-loader": "^0.5.4",
"lbry-format": "https://github.com/lbryio/lbry-format.git",
"lbry-redux": "lbryio/lbry-redux#ecfcc95bebbdbe303b3ea065134457a5e168fb89",
"lbry-redux": "lbryio/lbry-redux#5cd9e7601cdc1c81b8d0e2d2f02e9b9d2fb86575",
"lbryinc": "lbryio/lbryinc#8f9a58bfc8312a65614fd7327661cdcc502c4e59",
"lint-staged": "^7.0.2",
"localforage": "^1.7.1",

View file

@ -1917,5 +1917,35 @@
"Set to current date and time": "Set to current date and time",
"Remove custom release date": "Remove custom release date",
"%SITE_NAME% login": "%SITE_NAME% login",
"Lists": "Lists",
"Watch Later": "Watch Later",
"Add to Lists": "Add to Lists",
"Nothing in %collection_name%": "Nothing in %collection_name%",
"Playlists": "Playlists",
"Edit List": "Edit List",
"Delete List": "Delete List",
"Private": "Private",
"View List": "View List",
"Delete Collection": "Delete Collection",
"Info": "Info",
"Publishes": "Publishes",
"Add To...": "Add To...",
"Unpublished Edits": "Unpublished Edits",
"Report channel": "Report channel",
"List": "List",
"Items": "Items",
"Credits": "Credits",
"MyAwesomeCollection": "MyAwesomeCollection",
"My Awesome Collection": "My Awesome Collection",
"This collection has no items.": "This collection has no items.",
"Select File": "Select File",
"File Selected": "File Selected",
"Url": "Url",
"URL Selected": "URL Selected",
"Keep": "Keep",
"Add this claim to a list": "Add this claim to a list",
"List is Empty": "List is Empty",
"Confirm Collection Unpublish": "Confirm Collection Unpublish",
"This will permanently delete the list.": "This will permanently delete the list.",
"--end--": "--end--"
}

View file

@ -5,7 +5,7 @@ import { selectGetSyncErrorMessage, selectSyncFatalError } from 'redux/selectors
import { doFetchAccessToken, doUserSetReferrer } from 'redux/actions/user';
import { selectUser, selectAccessToken, selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectUnclaimedRewards } from 'redux/selectors/rewards';
import { doFetchChannelListMine, selectMyChannelUrls, SETTINGS } from 'lbry-redux';
import { doFetchChannelListMine, doFetchCollectionListMine, SETTINGS, selectMyChannelUrls } from 'lbry-redux';
import {
makeSelectClientSetting,
selectLanguage,
@ -52,6 +52,7 @@ const select = (state) => ({
const perform = (dispatch) => ({
fetchAccessToken: () => dispatch(doFetchAccessToken()),
fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
fetchCollectionListMine: () => dispatch(doFetchCollectionListMine()),
setLanguage: (language) => dispatch(doSetLanguage(language)),
signIn: () => dispatch(doSignIn()),
requestDownloadUpgrade: () => dispatch(doDownloadUpgradeRequested()),

View file

@ -61,6 +61,7 @@ type Props = {
},
fetchAccessToken: () => void,
fetchChannelListMine: () => void,
fetchCollectionListMine: () => void,
signIn: () => void,
requestDownloadUpgrade: () => void,
onSignedIn: () => void,
@ -94,6 +95,7 @@ function App(props: Props) {
user,
fetchAccessToken,
fetchChannelListMine,
fetchCollectionListMine,
signIn,
autoUpdateDownloaded,
isUpgradeAvailable,
@ -233,8 +235,9 @@ function App(props: Props) {
// @if TARGET='app'
fetchChannelListMine(); // This is fetched after a user is signed in on web
fetchCollectionListMine();
// @endif
}, [appRef, fetchAccessToken, fetchChannelListMine]);
}, [appRef, fetchAccessToken, fetchChannelListMine, fetchCollectionListMine]);
useEffect(() => {
// $FlowFixMe

View file

@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, SETTINGS } from 'lbry-redux';
import { makeSelectClaimForUri, SETTINGS, COLLECTIONS_CONSTS, makeSelectNextUrlForCollectionAndUrl } from 'lbry-redux';
import { withRouter } from 'react-router';
import { makeSelectIsPlayerFloating, makeSelectNextUnplayedRecommended } from 'redux/selectors/content';
import { makeSelectClientSetting } from 'redux/selectors/settings';
@ -8,11 +8,24 @@ import AutoplayCountdown from './view';
import { selectModal } from 'redux/selectors/app';
/*
AutoplayCountdown does not fetch it's own next content to play, it relies on <RecommendedContent> being rendered. This is dumb but I'm just the guy who noticed
AutoplayCountdown does not fetch it's own next content to play, it relies on <RecommendedContent> being rendered.
This is dumb but I'm just the guy who noticed -kj
*/
const select = (state, props) => {
const nextRecommendedUri = makeSelectNextUnplayedRecommended(props.uri)(state);
const { location } = props;
const { search } = location;
const urlParams = new URLSearchParams(search);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
let nextRecommendedUri;
if (collectionId) {
nextRecommendedUri = makeSelectNextUrlForCollectionAndUrl(collectionId, props.uri)(state);
} else {
nextRecommendedUri = makeSelectNextUnplayedRecommended(props.uri)(state);
}
return {
collectionId,
nextRecommendedUri,
nextRecommendedClaim: makeSelectClaimForUri(nextRecommendedUri)(state),
isFloating: makeSelectIsPlayerFloating(props.location)(state),

View file

@ -6,18 +6,19 @@ import I18nMessage from 'component/i18nMessage';
import { formatLbryUrlForWeb } from 'util/url';
import { withRouter } from 'react-router';
import debounce from 'util/debounce';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
const DEBOUNCE_SCROLL_HANDLER_MS = 150;
const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown';
type Props = {
history: { push: string => void },
history: { push: (string) => void },
nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string,
isFloating: boolean,
doSetPlayingUri: ({ uri: ?string }) => void,
doPlayUri: string => void,
doPlayUri: (string) => void,
modal: { id: string, modalProps: {} },
collectionId?: string,
};
function AutoplayCountdown(props: Props) {
@ -29,6 +30,7 @@ function AutoplayCountdown(props: Props) {
isFloating,
history: { push },
modal,
collectionId,
} = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -44,6 +46,11 @@ function AutoplayCountdown(props: Props) {
let navigateUrl;
if (nextTitle) {
navigateUrl = formatLbryUrlForWeb(nextRecommendedUri);
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
}
const doNavigate = useCallback(() => {
@ -71,7 +78,7 @@ function AutoplayCountdown(props: Props) {
// Ensure correct 'setTimerPaused' on initial render.
setTimerPaused(shouldPauseAutoplay());
const handleScroll = debounce(e => {
const handleScroll = debounce((e) => {
setTimerPaused(shouldPauseAutoplay());
}, DEBOUNCE_SCROLL_HANDLER_MS);

View file

@ -32,6 +32,7 @@ type Props = {
tileLayout: boolean,
viewHiddenChannels: boolean,
doResolveUris: (Array<string>, boolean) => void,
claimType: string,
};
function ChannelContent(props: Props) {
@ -49,6 +50,7 @@ function ChannelContent(props: Props) {
tileLayout,
viewHiddenChannels,
doResolveUris,
claimType,
} = props;
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
const [searchQuery, setSearchQuery] = React.useState('');
@ -58,6 +60,7 @@ function ChannelContent(props: Props) {
} = useHistory();
const url = `${pathname}${search}`;
const claimId = claim && claim.claim_id;
const showFilters = !claimType || claimType === 'stream';
function handleInputChange(e) {
const { value } = e.target;
@ -134,25 +137,30 @@ function ChannelContent(props: Props) {
<ClaimListDiscover
showHiddenByUser={viewHiddenChannels}
forceShowReposts
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
uris={searchResults}
channelIds={[claim.claim_id]}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
isChannel
channelIsMine={channelIsMine}

View file

@ -6,13 +6,22 @@ import Button from 'component/button';
type Props = {
doOpenModal: (string, {}) => void,
claim: StreamClaim,
abandonActionCallback: any => void,
claim: Claim,
abandonActionCallback: (any) => void,
iconSize: number,
};
export default function ClaimAbandonButton(props: Props) {
const { doOpenModal, claim, abandonActionCallback } = props;
const { value_type } = claim || {};
let buttonLabel;
if (value_type === 'channel') {
buttonLabel = __('Delete Channel');
} else if (value_type === 'collection') {
buttonLabel = __('Delete List');
} else if (value_type === 'stream') {
buttonLabel = __('Delete Publish');
}
function abandonClaim() {
doOpenModal(MODALS.CONFIRM_CLAIM_REVOKE, { claim: claim, cb: abandonActionCallback });
@ -21,7 +30,7 @@ export default function ClaimAbandonButton(props: Props) {
return (
<Button
disabled={!claim}
label={__('Delete Channel')}
label={buttonLabel}
button="alt"
iconColor="red"
icon={ICONS.DELETE}

View file

@ -1,9 +1,9 @@
import { connect } from 'react-redux';
import { makeSelectChannelForClaimUri } from 'lbry-redux';
import FileAuthor from './view';
import ClaimAuthor from './view';
const select = (state, props) => ({
channelUri: makeSelectChannelForClaimUri(props.uri)(state),
});
export default connect(select)(FileAuthor);
export default connect(select)(ClaimAuthor);

View file

@ -7,7 +7,7 @@ type Props = {
hideActions?: boolean,
};
function FileAuthor(props: Props) {
function ClaimAuthor(props: Props) {
const { channelUri, hideActions } = props;
return channelUri ? (
@ -17,4 +17,4 @@ function FileAuthor(props: Props) {
);
}
export default FileAuthor;
export default ClaimAuthor;

View file

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import ClaimCollectionAdd from './view';
import { withRouter } from 'react-router';
import {
makeSelectClaimForUri,
doLocalCollectionCreate,
selectBuiltinCollections,
selectMyPublishedCollections,
selectMyUnpublishedCollections,
} from 'lbry-redux';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
builtin: selectBuiltinCollections(state),
published: selectMyPublishedCollections(state),
unpublished: selectMyUnpublishedCollections(state),
});
const perform = (dispatch) => ({
addCollection: (name, items, type) => dispatch(doLocalCollectionCreate(name, items, type)),
});
export default withRouter(connect(select, perform)(ClaimCollectionAdd));

View file

@ -0,0 +1,133 @@
// @flow
import React from 'react';
import type { ElementRef } from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import { FormField } from 'component/common/form';
import * as ICONS from 'constants/icons';
import CollectionSelectItem from 'component/collectionSelectItem';
type Props = {
claim: Claim,
builtin: any,
published: any,
unpublished: any,
addCollection: (string, Array<string>, string) => void,
closeModal: () => void,
uri: string,
};
const ClaimCollectionAdd = (props: Props) => {
const { builtin, published, unpublished, addCollection, claim, closeModal, uri } = props;
const buttonref: ElementRef<any> = React.useRef();
const permanentUrl = claim && claim.permanent_url;
const isChannel = claim && claim.value_type === 'channel';
const [addNewCollection, setAddNewCollection] = React.useState(false);
const [newCollectionName, setNewCollectionName] = React.useState('');
// TODO: when other collection types added, filter list in context
// const isPlayable =
// claim &&
// claim.value &&
// // $FlowFixMe
// claim.value.stream_type &&
// (claim.value.stream_type === 'audio' || claim.value.stream_type === 'video');
function handleNameInput(e) {
const { value } = e.target;
setNewCollectionName(value);
}
function handleAddCollection() {
addCollection(newCollectionName, [permanentUrl], isChannel ? 'collection' : 'playlist');
setNewCollectionName('');
}
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
const KEYCODE_ENTER = 13;
if (e.keyCode === KEYCODE_ENTER) {
e.preventDefault();
buttonref.current.click();
}
}
function onTextareaFocus() {
window.addEventListener('keydown', altEnterListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', altEnterListener);
}
return (
<Card
title={__('Add To...')}
actions={
<div className="card__body">
{uri && (
<fieldset-section>
<div className={'card__body-scrollable'}>
{(Object.values(builtin): any)
// $FlowFixMe
.filter((list) => (isChannel ? list.type === 'collection' : list.type === 'playlist'))
.map((l) => {
const { id } = l;
return <CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'builtin'} />;
})}
{unpublished &&
(Object.values(unpublished): any)
// $FlowFixMe
.filter((list) => (isChannel ? list.type === 'collection' : list.type === 'playlist'))
.map((l) => {
const { id } = l;
return (
<CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'unpublished'} />
);
})}
{published &&
(Object.values(published): any).map((l) => {
// $FlowFixMe
const { id } = l;
return (
<CollectionSelectItem collectionId={id} uri={permanentUrl} key={id} category={'published'} />
);
})}
</div>
</fieldset-section>
)}
<fieldset-section>
{addNewCollection && (
<FormField
autoFocus
type="text"
name="new_collection"
value={newCollectionName}
label={'New List Title'}
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
inputButton={
<Button
button={'secondary'}
icon={ICONS.ADD}
disabled={!newCollectionName.length}
onClick={() => handleAddCollection()}
ref={buttonref}
/>
}
onChange={handleNameInput}
/>
)}
{!addNewCollection && (
<Button button={'link'} label={'New List'} onClick={() => setAddNewCollection(true)} />
)}
</fieldset-section>
<div className="card__actions">
<Button button="secondary" label={__('Done')} onClick={closeModal} />
</div>
</div>
}
/>
);
};
export default ClaimCollectionAdd;

View file

@ -0,0 +1,12 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import PlaylistAddButton from './view';
import { makeSelectClaimForUri } from 'lbry-redux';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
});
export default connect(select, {
doOpenModal,
})(PlaylistAddButton);

View file

@ -0,0 +1,40 @@
// @flow
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
import classnames from 'classnames';
type Props = {
uri: string,
doOpenModal: (string, {}) => void,
fileAction?: boolean,
type?: boolean,
claim: Claim,
};
export default function CollectionAddButton(props: Props) {
const { doOpenModal, uri, fileAction, type = 'playlist', claim } = props;
// $FlowFixMe
const streamType = (claim && claim.value && claim.value.stream_type) || '';
const isPlayable = streamType === 'video' || streamType === 'audio';
if (!isPlayable) return null;
return (
<Button
button={fileAction ? undefined : 'alt'}
className={classnames({ 'button--file-action': fileAction })}
icon={fileAction ? ICONS.ADD : ICONS.LIBRARY}
iconSize={fileAction ? 22 : undefined}
label={uri ? __('Save') : 'New List'}
requiresAuth={IS_WEB}
title={__('Add this claim to a list')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
doOpenModal(MODALS.COLLECTION_ADD, { uri, type });
}}
/>
);
}

View file

@ -9,6 +9,4 @@ const select = (state) => ({
claimsByUri: selectClaimsByUri(state),
});
const perform = (dispatch) => ({});
export default connect(select, perform)(ClaimList);
export default connect(select)(ClaimList);

View file

@ -45,6 +45,7 @@ type Props = {
livestreamMap?: { [string]: any },
searchOptions?: any,
channelIsMine: boolean,
collectionId?: string,
};
export default function ClaimList(props: Props) {
@ -76,6 +77,7 @@ export default function ClaimList(props: Props) {
livestreamMap,
searchOptions,
channelIsMine,
collectionId,
} = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
@ -135,6 +137,7 @@ export default function ClaimList(props: Props) {
properties={renderProperties}
live={resolveLive(index)}
channelIsMine={channelIsMine}
collectionId={collectionId}
/>
))}
{!timedOut && urisLength === 0 && !loading && <div className="empty main--empty">{empty || noResultMsg}</div>}
@ -194,6 +197,7 @@ export default function ClaimList(props: Props) {
renderActions={renderActions}
showUserBlocked={showHiddenByUser}
showHiddenByUser={showHiddenByUser}
collectionId={collectionId}
customShouldHide={(claim: StreamClaim) => {
// Hack to hide spee.ch thumbnail publishes
// If it meets these requirements, it was probably uploaded here:

View file

@ -29,7 +29,7 @@ type Props = {
channelIds?: Array<string>,
tileLayout: boolean,
doSetClientSetting: (string, boolean, ?boolean) => void,
setPage: number => void,
setPage: (number) => void,
hideFilters: boolean,
searchInLanguage: boolean,
languageSetting: string,
@ -74,7 +74,7 @@ function ClaimListHeader(props: Props) {
const channelIdsInUrl = urlParams.get(CS.CHANNEL_IDS_KEY);
const channelIdsParam = channelIdsInUrl ? channelIdsInUrl.split(',') : channelIds;
const feeAmountParam = urlParams.get('fee_amount') || feeAmount || CS.FEE_AMOUNT_ANY;
const showDuration = !(claimType && claimType === CS.CLAIM_CHANNEL);
const showDuration = !(claimType && claimType === CS.CLAIM_CHANNEL && claimType === CS.CLAIM_COLLECTION);
const isFiltered = () =>
Boolean(
urlParams.get(CS.FRESH_KEY) ||
@ -144,7 +144,7 @@ function ClaimListHeader(props: Props) {
function buildUrl(delta) {
const newUrlParams = new URLSearchParams(location.search);
CS.KEYS.forEach(k => {
CS.KEYS.forEach((k) => {
// $FlowFixMe get() can return null
if (urlParams.get(k) !== null) newUrlParams.set(k, urlParams.get(k));
});
@ -161,7 +161,11 @@ function ClaimListHeader(props: Props) {
}
break;
case CS.CONTENT_KEY:
if (delta.value === CS.CLAIM_CHANNEL || delta.value === CS.CLAIM_REPOST) {
if (
delta.value === CS.CLAIM_CHANNEL ||
delta.value === CS.CLAIM_REPOST ||
delta.value === CS.CLAIM_COLLECTION
) {
newUrlParams.delete(CS.DURATION_KEY);
newUrlParams.set(CS.CONTENT_KEY, delta.value);
} else if (delta.value === CS.CONTENT_ALL) {
@ -212,13 +216,13 @@ function ClaimListHeader(props: Props) {
<>
<div className="claim-search__wrapper">
<div className="claim-search__top">
<div className="claim-search__menu-group">
{!hideFilters &&
CS.ORDER_BY_TYPES.map(type => (
{!hideFilters && (
<div className="claim-search__menu-group">
{CS.ORDER_BY_TYPES.map((type) => (
<Button
key={type}
button="alt"
onClick={e =>
onClick={(e) =>
handleChange({
key: CS.ORDER_BY_KEY,
value: type,
@ -233,8 +237,8 @@ function ClaimListHeader(props: Props) {
label={__(toCapitalCase(type))}
/>
))}
</div>
</div>
)}
<div className="claim-search__menu-group">
{!hideAdvancedFilter && !SIMPLE_SITE && (
<Button
@ -276,21 +280,23 @@ function ClaimListHeader(props: Props) {
name="trending_time"
label={__('How Fresh')}
value={freshnessParam}
onChange={e =>
onChange={(e) =>
handleChange({
key: CS.FRESH_KEY,
value: e.target.value,
})
}
>
{CS.FRESH_TYPES.map(time => (
{CS.FRESH_TYPES.map((time) => (
<option key={time} value={time}>
{/* i18fixme */}
{time === CS.FRESH_DAY && __('Today')}
{time !== CS.FRESH_ALL &&
time !== CS.FRESH_DEFAULT &&
time !== CS.FRESH_DAY &&
__('This ' + toCapitalCase(time)) /* yes, concat before i18n, since it is read from const */}
{
time !== CS.FRESH_ALL &&
time !== CS.FRESH_DEFAULT &&
time !== CS.FRESH_DAY &&
__('This ' + toCapitalCase(time)) /* yes, concat before i18n, since it is read from const */
}
{time === CS.FRESH_ALL && __('All time')}
{time === CS.FRESH_DEFAULT && __('Default')}
</option>
@ -314,18 +320,19 @@ function ClaimListHeader(props: Props) {
name="claimType"
label={__('Content Type')}
value={contentTypeParam || CS.CONTENT_ALL}
onChange={e =>
onChange={(e) =>
handleChange({
key: CS.CONTENT_KEY,
value: e.target.value,
})
}
>
{CS.CONTENT_TYPES.map(type => {
{CS.CONTENT_TYPES.map((type) => {
if (type !== CS.CLAIM_CHANNEL || (type === CS.CLAIM_CHANNEL && !channelIdsParam)) {
return (
<option key={type} value={type}>
{/* i18fixme */}
{type === CS.CLAIM_COLLECTION && __('List')}
{type === CS.CLAIM_CHANNEL && __('Channel')}
{type === CS.CLAIM_REPOST && __('Repost')}
{type === CS.FILE_VIDEO && __('Video')}
@ -358,7 +365,7 @@ function ClaimListHeader(props: Props) {
name="claimType"
label={__('Language')}
value={languageValue || CS.LANGUAGES_ALL}
onChange={e =>
onChange={(e) =>
handleChange({
key: CS.LANGUAGE_KEY,
value: e.target.value,
@ -398,14 +405,14 @@ function ClaimListHeader(props: Props) {
)
}
value={durationParam || CS.DURATION_ALL}
onChange={e =>
onChange={(e) =>
handleChange({
key: CS.DURATION_KEY,
value: e.target.value,
})
}
>
{CS.DURATION_TYPES.map(dur => (
{CS.DURATION_TYPES.map((dur) => (
<option key={dur} value={dur}>
{/* i18fixme */}
{dur === CS.DURATION_SHORT && __('Short (< 4 minutes)')}
@ -428,7 +435,7 @@ function ClaimListHeader(props: Props) {
type="select"
name="paidcontent"
value={feeAmountParam}
onChange={e =>
onChange={(e) =>
handleChange({
key: CS.FEE_AMOUNT_KEY,
value: e.target.value,

View file

@ -1,20 +1,48 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, makeSelectClaimIsMine } from 'lbry-redux';
import {
doCollectionEdit,
makeSelectClaimForUri,
makeSelectClaimIsMine,
makeSelectCollectionForIdHasClaimUrl,
makeSelectNameForCollectionId,
makeSelectCollectionIsMine,
COLLECTIONS_CONSTS,
} from 'lbry-redux';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doToggleMuteChannel } from 'redux/actions/blocked';
import { doCommentModBlock, doCommentModUnBlock } from 'redux/actions/comments';
import { makeSelectChannelIsBlocked } from 'redux/selectors/comments';
import { doOpenModal } from 'redux/actions/app';
import { doToast } from 'redux/actions/notifications';
import { makeSelectUserPropForProp } from 'redux/selectors/user';
import ClaimPreview from './view';
import * as USER from 'constants/user';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: props.channelIsMine ? (props.isRepost ? makeSelectClaimIsMine(props.uri)(state) : true) : makeSelectClaimIsMine(props.uri)(state),
channelIsMuted: makeSelectChannelIsMuted(props.uri)(state),
channelIsBlocked: makeSelectChannelIsBlocked(props.uri)(state),
});
const select = (state, props) => {
const claim = makeSelectClaimForUri(props.uri)(state);
const permanentUri = claim && claim.permanent_url;
return {
claim,
claimIsMine: props.channelIsMine
? props.isRepost
? makeSelectClaimIsMine(props.uri)(state)
: true
: makeSelectClaimIsMine(props.uri)(state),
hasClaimInWatchLater: makeSelectCollectionForIdHasClaimUrl(COLLECTIONS_CONSTS.WATCH_LATER_ID, permanentUri)(state),
channelIsMuted: makeSelectChannelIsMuted(props.uri)(state),
channelIsBlocked: makeSelectChannelIsBlocked(props.uri)(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(props.collectionId, permanentUri)(state),
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state),
hasExperimentalUi: makeSelectUserPropForProp(USER.EXPERIMENTAL_UI)(state),
};
};
export default connect(select, {
doToggleMuteChannel,
doCommentModBlock,
doCommentModUnBlock,
doCollectionEdit,
doOpenModal,
doToast,
})(ClaimPreview);

View file

@ -2,12 +2,14 @@
import { URL, SHARE_DOMAIN_URL } from 'config';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import React from 'react';
import classnames from 'classnames';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import { generateShareUrl } from 'util/url';
import { useHistory } from 'react-router';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
const SHARE_DOMAIN = SHARE_DOMAIN_URL || URL;
@ -23,6 +25,15 @@ type Props = {
doCommentModUnBlock: (string) => void,
channelIsMine: boolean,
isRepost: boolean,
doCollectionEdit: (string, any) => void,
hasClaimInWatchLater: boolean,
doOpenModal: (string, {}) => void,
claimInCollection: boolean,
collectionName?: string,
collectionId: string,
isMyCollection: boolean,
doToast: ({ message: string }) => void,
hasExperimentalUi: boolean,
};
function ClaimMenuList(props: Props) {
@ -36,21 +47,36 @@ function ClaimMenuList(props: Props) {
channelIsBlocked,
doCommentModBlock,
doCommentModUnBlock,
doCollectionEdit,
hasClaimInWatchLater,
doOpenModal,
collectionId,
collectionName,
isMyCollection,
doToast,
hasExperimentalUi,
} = props;
const { push } = useHistory();
const channelUri =
claim &&
(claim.value_type === 'channel'
? claim.permanent_url
: claim.signing_channel && claim.signing_channel.permanent_url);
const shareUrl: string = generateShareUrl(SHARE_DOMAIN, uri);
if (!channelUri || !claim) {
if (!claim) {
return null;
}
const channelUri = claim
? claim.value_type === 'channel'
? claim.permanent_url
: (claim.signing_channel && claim.signing_channel.permanent_url) || ''
: '';
const shareUrl: string = generateShareUrl(SHARE_DOMAIN, uri);
const isCollectionClaim = claim && claim.value_type === 'collection';
// $FlowFixMe
const isPlayable =
claim &&
!claim.repost_url &&
// $FlowFixMe
claim.value.stream_type &&
// $FlowFixMe
(claim.value.stream_type === 'audio' || claim.value.stream_type === 'video');
function handleToggleMute() {
doToggleMuteChannel(channelUri);
@ -84,7 +110,69 @@ function ClaimMenuList(props: Props) {
<Icon size={20} icon={ICONS.MORE_VERTICAL} />
</MenuButton>
<MenuList className="menu__list">
{!claimIsMine && (
{hasExperimentalUi && (
<>
{/* WATCH LATER */}
{isPlayable && !collectionId && (
<>
<MenuItem
className="comment__menu-option"
onSelect={() => {
doToast({
message: __('Item %action% Watch Later', {
action: hasClaimInWatchLater ? __('removed from') : __('added to'),
}),
});
doCollectionEdit(COLLECTIONS_CONSTS.WATCH_LATER_ID, {
claims: [claim],
remove: hasClaimInWatchLater,
type: 'playlist',
});
}}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
{hasClaimInWatchLater ? __('In Watch Later') : __('Watch Later')}
</div>
</MenuItem>
</>
)}
{/* COLLECTION OPERATIONS */}
{collectionId && collectionName && isCollectionClaim && (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => doOpenModal(MODALS.COLLECTION_DELETE, { collectionId })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete List')}
</div>
</MenuItem>
</>
)}
{/* CURRENTLY ONLY SUPPORT PLAYLISTS FOR PLAYABLE; LATER DIFFERENT TYPES */}
{isPlayable && (
<MenuItem
className="comment__menu-option"
onSelect={() => doOpenModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.STACK} />
{__('Add to Lists')}
</div>
</MenuItem>
)}
</>
)}
<hr className="menu__separator" />
{channelUri && !claimIsMine && !isMyCollection && (
<>
<MenuItem className="comment__menu-option" onSelect={handleToggleBlock}>
<div className="menu__link">
@ -111,7 +199,7 @@ function ClaimMenuList(props: Props) {
</div>
</MenuItem>
{!claimIsMine && (
{!claimIsMine && !isMyCollection && (
<MenuItem className="comment__menu-option" onSelect={handleReportContent}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.REPORT} />

View file

@ -11,6 +11,10 @@ import {
makeSelectClaimWasPurchased,
makeSelectStreamingUrlForUri,
makeSelectClaimIsStreamPlaceholder,
makeSelectCollectionIsMine,
doCollectionEdit,
makeSelectUrlsForCollectionId,
makeSelectIndexForUrlInCollection,
} from 'lbry-redux';
import { selectMutedChannels, makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc';
@ -40,11 +44,15 @@ const select = (state, props) => ({
streamingUrl: props.uri && makeSelectStreamingUrlForUri(props.uri)(state),
wasPurchased: props.uri && makeSelectClaimWasPurchased(props.uri)(state),
isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state),
collectionUris: makeSelectUrlsForCollectionId(props.collectionId)(state),
collectionIndex: makeSelectIndexForUrlInCollection(props.uri, props.collectionId)(state),
});
const perform = (dispatch) => ({
resolveUri: (uri) => dispatch(doResolveUri(uri)),
getFile: (uri) => dispatch(doFileGet(uri, false)),
editCollection: (id, params) => dispatch(doCollectionEdit(id, params)),
});
export default connect(select, perform)(ClaimPreview);

View file

@ -3,12 +3,12 @@ import type { Node } from 'react';
import React, { useEffect, forwardRef } from 'react';
import { NavLink, withRouter } from 'react-router-dom';
import classnames from 'classnames';
import { parseURI } from 'lbry-redux';
import { parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb } from 'util/url';
import { isEmpty } from 'util/object';
import FileThumbnail from 'component/fileThumbnail';
import UriIndicator from 'component/uriIndicator';
import FileProperties from 'component/fileProperties';
import PreviewOverlayProperties from 'component/previewOverlayProperties';
import ClaimTags from 'component/claimTags';
import SubscribeButton from 'component/subscribeButton';
import ChannelThumbnail from 'component/channelThumbnail';
@ -25,10 +25,12 @@ import ClaimPreviewLoading from './claim-preview-loading';
import ClaimPreviewHidden from './claim-preview-no-mature';
import ClaimPreviewNoContent from './claim-preview-no-content';
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
type Props = {
uri: string,
claim: ?Claim,
claim: ?Claim, // maybe?
obscureNsfw: boolean,
showUserBlocked: boolean,
claimIsMine: boolean,
@ -71,6 +73,11 @@ type Props = {
hideMenu?: boolean,
isLivestream?: boolean,
live?: boolean,
collectionId?: string,
editCollection: (string, CollectionEditParams) => void,
isCollectionMine: boolean,
collectionUris: Array<Collection>,
collectionIndex?: number,
};
const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
@ -120,15 +127,22 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
renderActions,
hideMenu = false,
// repostUrl,
isLivestream,
isLivestream, // need both? CHECK
live,
collectionId,
collectionIndex,
editCollection,
isCollectionMine,
collectionUris,
} = props;
const WrapperElement = wrapperElement || 'li';
const shouldFetch =
claim === undefined || (claim !== null && claim.value_type === 'channel' && isEmpty(claim.meta) && !pending);
const abandoned = !isResolvingUri && !claim;
const shouldHideActions = hideActions || type === 'small' || type === 'tooltip';
const isMyCollection = collectionId && (isCollectionMine || collectionId.includes('-'));
const shouldHideActions = hideActions || isMyCollection || type === 'small' || type === 'tooltip';
const canonicalUrl = claim && claim.canonical_url;
const lastCollectionIndex = collectionUris ? collectionUris.length - 1 : 0;
let isValid = false;
if (uri) {
try {
@ -138,12 +152,15 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
isValid = false;
}
}
const isRepost = claim && claim.repost_url;
const contentUri = claim && isRepost ? claim.canonical_url || claim.permanent_url : uri;
const isChannelUri = isValid ? parseURI(contentUri).isChannel : false;
const isCollection = claim && claim.value_type === 'collection';
const isChannelUri = isValid ? parseURI(uri).isChannel : false;
const signingChannel = claim && claim.signing_channel;
const navigateUrl = formatLbryUrlForWeb((claim && claim.canonical_url) || uri || '/');
let navigateUrl = formatLbryUrlForWeb((claim && claim.canonical_url) || uri || '/');
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
const navLinkProps = {
to: navigateUrl,
onClick: (e) => e.stopPropagation(),
@ -188,7 +205,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
// Weird placement warning
// Make sure this happens after we figure out if this claim needs to be hidden
const thumbnailUrl = useGetThumbnail(contentUri, claim, streamingUrl, getFile, shouldHide);
const thumbnailUrl = useGetThumbnail(uri, claim, streamingUrl, getFile, shouldHide);
function handleOnClick(e) {
if (onClick) {
@ -232,10 +249,10 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
return empty || <ClaimPreviewNoContent isChannel={isChannelUri} type={type} />;
}
if (!shouldFetch && showUnresolvedClaim && !isResolvingUri && claim === null) {
return <AbandonedChannelPreview uri={contentUri} type />;
if (!shouldFetch && showUnresolvedClaim && !isResolvingUri && isChannelUri && claim === null) {
return <AbandonedChannelPreview uri={uri} type />;
}
if (placeholder === 'publish' && !claim && contentUri.startsWith('lbry://@')) {
if (placeholder === 'publish' && !claim && uri.startsWith('lbry://@')) {
return null;
}
@ -271,8 +288,8 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
})}
>
{isChannelUri && claim ? (
<UriIndicator uri={contentUri} link>
<ChannelThumbnail uri={contentUri} />
<UriIndicator uri={uri} link>
<ChannelThumbnail uri={uri} />
</UriIndicator>
) : (
<>
@ -280,15 +297,15 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
<NavLink {...navLinkProps}>
<FileThumbnail thumbnail={thumbnailUrl}>
{/* @if TARGET='app' */}
{claim && (
{claim && !isCollection && (
<div className="claim-preview__hover-actions">
<FileDownloadLink uri={canonicalUrl} hideOpenButton hideDownloadStatus />
</div>
)}
{/* @endif */}
{!isRepost && !isChannelUri && (
{!isLivestream && (
<div className="claim-preview__file-property-overlay">
<FileProperties uri={contentUri} small properties={liveProperty} />
<PreviewOverlayProperties uri={uri} small={type === 'small'} properties={liveProperty} />
</div>
)}
</FileThumbnail>
@ -303,7 +320,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
<div className="claim-preview-metadata">
<div className="claim-preview-info">
{pending ? (
<ClaimPreviewTitle uri={contentUri} />
<ClaimPreviewTitle uri={uri} />
) : (
<NavLink {...navLinkProps}>
<ClaimPreviewTitle uri={uri} />
@ -318,6 +335,58 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
{!pending && (
<>
{renderActions && claim && renderActions(claim)}
{Boolean(isMyCollection && collectionId) && (
<>
<div className="collection-preview__edit-buttons">
<div className="collection-preview__edit-group">
<Button
button="alt"
className={'button-collection-order'}
disabled={collectionIndex === 0}
icon={ICONS.UP}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (editCollection) {
// $FlowFixMe
editCollection(collectionId, {
order: { from: collectionIndex, to: Number(collectionIndex || 0) + 1 },
});
}
}}
/>
<Button
button="alt"
className={'button-collection-order'}
icon={ICONS.DOWN}
disabled={collectionIndex === lastCollectionIndex}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (editCollection) {
// $FlowFixMe
editCollection(collectionId, {
order: { from: collectionIndex, to: Number(collectionIndex + 1) },
});
}
}}
/>
</div>
<div className="collection-preview__edit-group">
<Button
button="alt"
icon={ICONS.DELETE}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// $FlowFixMe
if (editCollection) editCollection(collectionId, { claims: [claim], remove: true });
}}
/>
</div>
</div>
</>
)}
{shouldHideActions || renderActions ? null : actions !== undefined ? (
actions
) : (
@ -329,9 +398,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
)}
{isChannelUri && !channelIsBlocked && !claimIsMine && (
<SubscribeButton
uri={contentUri.startsWith('lbry://') ? contentUri : `lbry://${contentUri}`}
/>
<SubscribeButton uri={uri.startsWith('lbry://') ? uri : `lbry://${uri}`} />
)}
{includeSupportAction && <ClaimSupportButton uri={uri} />}
@ -354,7 +421,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
)}
</div>
</div>
{!hideMenu && <ClaimMenuList uri={uri} />}
{!hideMenu && <ClaimMenuList uri={uri} collectionId={collectionId} />}
</>
</WrapperElement>
);

View file

@ -10,11 +10,12 @@ import ChannelThumbnail from 'component/channelThumbnail';
import SubscribeButton from 'component/subscribeButton';
import useGetThumbnail from 'effects/use-get-thumbnail';
import { formatLbryUrlForWeb } from 'util/url';
import { parseURI } from 'lbry-redux';
import FileProperties from 'component/fileProperties';
import { parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
import PreviewOverlayProperties from 'component/previewOverlayProperties';
import FileDownloadLink from 'component/fileDownloadLink';
import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
type Props = {
uri: string,
@ -43,6 +44,7 @@ type Props = {
properties?: (Claim) => void,
live?: boolean,
channelIsMine?: boolean,
collectionId?: string,
};
function ClaimPreviewTile(props: Props) {
@ -66,13 +68,22 @@ function ClaimPreviewTile(props: Props) {
properties,
live,
channelIsMine,
collectionId,
} = props;
const isRepost = claim && claim.repost_channel_url;
const isCollection = claim && claim.value_type === 'collection';
const isStream = claim && claim.value_type === 'stream';
const collectionClaimId = isCollection && claim && claim.claim_id;
const shouldFetch = claim === undefined;
const thumbnailUrl = useGetThumbnail(uri, claim, streamingUrl, getFile, placeholder) || thumbnail;
const canonicalUrl = claim && claim.canonical_url;
const navigateUrl = formatLbryUrlForWeb(canonicalUrl || uri || '/');
let navigateUrl = formatLbryUrlForWeb(canonicalUrl || uri || '/');
const listId = collectionId || collectionClaimId;
if (listId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, listId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
const navLinkProps = {
to: navigateUrl,
onClick: (e) => e.stopPropagation(),
@ -178,12 +189,22 @@ function ClaimPreviewTile(props: Props) {
{!isChannel && (
<React.Fragment>
{/* @if TARGET='app' */}
<div className="claim-preview__hover-actions">
<FileDownloadLink uri={canonicalUrl} hideOpenButton />
</div>
{isStream && (
<div className="claim-preview__hover-actions">
<FileDownloadLink uri={canonicalUrl} hideOpenButton />
</div>
)}
{/* @endif */}
<div className="claim-preview__file-property-overlay">
<FileProperties uri={uri} small properties={liveProperty || properties} />
<PreviewOverlayProperties uri={uri} properties={liveProperty || properties} />
</div>
</React.Fragment>
)}
{isCollection && (
<React.Fragment>
<div className="claim-preview__collection-wrapper">
<CollectionPreviewOverlay collectionId={listId} uri={uri} />
</div>
</React.Fragment>
)}
@ -197,7 +218,8 @@ function ClaimPreviewTile(props: Props) {
<UriIndicator uri={uri} />
</div>
)}
<ClaimMenuList uri={uri} channelIsMine={channelIsMine} isRepost={isRepost} />
{/* CHECK CLAIM MENU LIST PARAMS (IS REPOST?) */}
<ClaimMenuList uri={uri} collectionId={listId} channelIsMine={channelIsMine} isRepost={isRepost} />
</h2>
</NavLink>
<div>

View file

@ -1,13 +1,12 @@
import { connect } from 'react-redux';
import { makeSelectFilePartlyDownloaded, makeSelectClaimIsMine, makeSelectClaimForUri } from 'lbry-redux';
import { makeSelectClaimIsMine, makeSelectClaimForUri } from 'lbry-redux';
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import FileProperties from './view';
import ClaimProperties from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
downloaded: makeSelectFilePartlyDownloaded(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
});
export default connect(select, null)(FileProperties);
export default connect(select, null)(ClaimProperties);

View file

@ -0,0 +1,38 @@
// @flow
import * as ICONS from 'constants/icons';
import * as React from 'react';
import classnames from 'classnames';
import Icon from 'component/common/icon';
import FilePrice from 'component/filePrice';
import ClaimType from 'component/claimType';
import * as COL from 'constants/collections';
type Props = {
uri: string,
isSubscribed: boolean,
small: boolean,
claim: Claim | CollectionClaim,
iconOnly: boolean,
};
export default function ClaimProperties(props: Props) {
const { uri, isSubscribed, small = false, claim, iconOnly } = props;
const isCollection = claim && claim.value_type === 'collection';
const size = small ? COL.ICON_SIZE : undefined;
// $FlowFixMe
return (
<div
className={classnames('claim-preview__overlay-properties', { 'claim-preview__overlay-properties--small': small })}
>
{
<>
<ClaimType uri={uri} small />
{/* // $FlowFixMe */}
{isCollection && claim && claim.value.claims && !iconOnly && <div>{claim.value.claims.length}</div>}
{isSubscribed && <Icon size={size} tooltip icon={ICONS.SUBSCRIBE} />}
<FilePrice hideFree uri={uri} />
</>
}
</div>
);
}

View file

@ -52,7 +52,7 @@ export default function ClaimTags(props: Props) {
}
return (
<div className={classnames('file-properties--small', { 'file-properties--large': type === 'large' })}>
<div className={classnames('claim__tags', { 'claim__tags--large': type === 'large' })}>
{tagsToDisplay.map((tag) => (
<Tag key={tag} title={tag} name={tag} />
))}

View file

@ -0,0 +1,10 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, makeSelectClaimIsStreamPlaceholder } from 'lbry-redux';
import FileType from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
});
export default connect(select)(FileType);

View file

@ -0,0 +1,29 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Icon from 'component/common/icon';
import * as COL from 'constants/collections';
type Props = {
claim: Claim,
small: boolean,
};
function ClaimType(props: Props) {
const { claim, small } = props;
const { value_type: claimType } = claim || {};
const size = small ? COL.ICON_SIZE : undefined;
if (claimType === 'collection') {
return <Icon size={size} icon={ICONS.STACK} />;
} else if (claimType === 'channel') {
return <Icon size={size} icon={ICONS.CHANNEL} />;
} else if (claimType === 'repost') {
return <Icon size={size} icon={ICONS.REPOST} />;
}
return <Icon icon={ICONS.DOWNLOADABLE} />;
}
export default ClaimType;

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import {
makeSelectClaimIsMine,
makeSelectClaimForUri,
selectMyChannelClaims,
makeSelectClaimIsPending,
makeSelectCollectionIsMine,
makeSelectEditedCollectionForId,
} from 'lbry-redux';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { doToast } from 'redux/actions/notifications';
import { doOpenModal } from 'redux/actions/app';
import CollectionActions from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
costInfo: makeSelectCostInfoForUri(props.uri)(state),
myChannels: selectMyChannelClaims(state),
claimIsPending: makeSelectClaimIsPending(props.uri)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(props.collectionId)(state)),
});
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)),
});
export default connect(select, perform)(CollectionActions);

View file

@ -0,0 +1,130 @@
// @flow
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
import { useIsMobile } from 'effects/use-screensize';
import ClaimSupportButton from 'component/claimSupportButton';
import FileReactions from 'component/fileReactions';
import { useHistory } from 'react-router';
import { EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
import classnames from 'classnames';
import { ENABLE_FILE_REACTIONS } from 'config';
type Props = {
uri: string,
claim: StreamClaim,
openModal: (id: string, { uri: string, claimIsMine?: boolean, isSupport?: boolean }) => void,
myChannels: ?Array<ChannelClaim>,
doToast: ({ message: string }) => void,
claimIsPending: boolean,
isMyCollection: boolean,
collectionId: string,
showInfo: boolean,
setShowInfo: (boolean) => void,
collectionHasEdits: boolean,
};
function CollectionActions(props: Props) {
const {
uri,
openModal,
claim,
claimIsPending,
isMyCollection,
collectionId,
showInfo,
setShowInfo,
collectionHasEdits,
} = props;
const { push } = useHistory();
const isMobile = useIsMobile();
const claimId = claim && claim.claim_id;
const webShareable = true; // collections have cost?
const lhsSection = (
<>
{ENABLE_FILE_REACTIONS && uri && <FileReactions uri={uri} />}
{uri && <ClaimSupportButton uri={uri} fileAction />}
{/* TODO Add ClaimRepostButton component */}
{uri && (
<Button
className="button--file-action"
icon={ICONS.SHARE}
label={__('Share')}
title={__('Share')}
onClick={() => openModal(MODALS.SOCIAL_SHARE, { uri, webShareable })}
/>
)}
</>
);
const rhsSection = (
<>
{isMyCollection && (
<Button
title={uri ? __('Update') : __('Publish')}
label={uri ? __('Update') : __('Publish')}
className={classnames('button--file-action')}
onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
icon={ICONS.PUBLISH}
iconColor={collectionHasEdits && 'red'}
iconSize={18}
disabled={claimIsPending}
/>
)}
{isMyCollection && (
<Button
className={classnames('button--file-action')}
title={__('Delete List')}
onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })}
icon={ICONS.DELETE}
iconSize={18}
description={__('Delete List')}
disabled={claimIsPending}
/>
)}
{!isMyCollection && (
<Button
title={__('Report content')}
className="button--file-action"
icon={ICONS.REPORT}
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
/>
)}
</>
);
const infoButton = (
<Button
title={__('Info')}
className={classnames('button-toggle', {
'button-toggle--active': showInfo,
})}
icon={ICONS.MORE}
onClick={() => setShowInfo(!showInfo)}
/>
);
if (isMobile) {
return (
<div className="media__actions">
{lhsSection}
{rhsSection}
{uri && <span>{infoButton}</span>}
</div>
);
} else {
return (
<div className="media__subtitle--between">
<div className="section__actions">
{lhsSection}
{rhsSection}
</div>
{uri && <>{infoButton}</>}
</div>
);
}
}
export default CollectionActions;

View file

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import CollectionContent from './view';
import {
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
makeSelectCollectionForId,
makeSelectClaimForClaimId,
makeSelectClaimIsMine,
} from 'lbry-redux';
const select = (state, props) => {
const claim = makeSelectClaimForClaimId(props.id)(state);
const url = claim && claim.permanent_url;
return {
collection: makeSelectCollectionForId(props.id)(state),
collectionUrls: makeSelectUrlsForCollectionId(props.id)(state),
collectionName: makeSelectNameForCollectionId(props.id)(state),
claim,
isMine: makeSelectClaimIsMine(url)(state),
};
};
export default connect(select)(CollectionContent);

View file

@ -0,0 +1,42 @@
// @flow
import React from 'react';
import ClaimList from 'component/claimList';
import Card from 'component/common/card';
import Button from 'component/button';
import * as PAGES from 'constants/pages';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
type Props = {
collectionUrls: Array<Claim>,
collectionName: string,
collection: any,
createUnpublishedCollection: (string, Array<any>, ?string) => void,
id: string,
claim: Claim,
isMine: boolean,
};
export default function CollectionContent(props: Props) {
const { collectionUrls, collectionName, id } = props;
return (
<Card
isBodyList
className="file-page__recommended"
title={
<span>
<Icon icon={ICONS.STACK} className="icon--margin-right" />
{collectionName}
</span>
}
titleActions={
<>
{/* TODO: BUTTON TO SAVE COLLECTION - Probably save/copy modal */}
<Button label={'View List'} button="link" navigate={`/$/${PAGES.LIST}/${id}`} />
</>
}
body={<ClaimList isCardBody type="small" uris={collectionUrls} collectionId={id} empty={__('List is Empty')} />}
/>
);
}

View file

@ -0,0 +1,53 @@
import { connect } from 'react-redux';
import {
makeSelectTitleForUri,
makeSelectThumbnailForUri,
makeSelectMetadataItemForUri,
doCollectionPublish,
doCollectionPublishUpdate,
makeSelectAmountForUri,
makeSelectClaimForUri,
selectUpdateCollectionError,
selectUpdatingCollection,
selectCreateCollectionError,
selectBalance,
doClearCollectionErrors,
selectCreatingCollection,
makeSelectCollectionForId,
makeSelectUrlsForCollectionId,
makeSelectClaimIdsForCollectionId,
} from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import CollectionPage from './view';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
title: makeSelectTitleForUri(props.uri)(state),
thumbnailUrl: makeSelectThumbnailForUri(props.uri)(state),
description: makeSelectMetadataItemForUri(props.uri, 'description')(state),
tags: makeSelectMetadataItemForUri(props.uri, 'tags')(state),
locations: makeSelectMetadataItemForUri(props.uri, 'locations')(state),
languages: makeSelectMetadataItemForUri(props.uri, 'languages')(state),
amount: makeSelectAmountForUri(props.uri)(state),
updateError: selectUpdateCollectionError(state),
updatingCollection: selectUpdatingCollection(state),
createError: selectCreateCollectionError(state),
creatingCollection: selectCreatingCollection(state),
balance: selectBalance(state),
activeChannelClaim: selectActiveChannelClaim(state),
incognito: selectIncognito(state),
collection: makeSelectCollectionForId(props.collectionId)(state),
collectionUrls: makeSelectUrlsForCollectionId(props.collectionId)(state),
collectionClaimIds: makeSelectClaimIdsForCollectionId(props.collectionId)(state),
});
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
publishCollectionUpdate: (params) => dispatch(doCollectionPublishUpdate(params)),
publishCollection: (params, collectionId) => dispatch(doCollectionPublish(params, collectionId)),
clearCollectionErrors: () => dispatch(doClearCollectionErrors()),
});
export default connect(select, perform)(CollectionPage);

View file

@ -0,0 +1,441 @@
// @flow
import { DOMAIN } from 'config';
import React from 'react';
import classnames from 'classnames';
import Button from 'component/button';
import TagsSearch from 'component/tagsSearch';
import ErrorText from 'component/common/error-text';
import ClaimAbandonButton from 'component/claimAbandonButton';
import ChannelSelector from 'component/channelSelector';
import ClaimList from 'component/claimList';
import Card from 'component/common/card';
import LbcSymbol from 'component/common/lbc-symbol';
import ThumbnailPicker from 'component/thumbnailPicker';
import { useHistory } from 'react-router-dom';
import { isNameValid, regexInvalidURI } from 'lbry-redux';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import { FormField } from 'component/common/form';
import { handleBidChange } from 'util/publish';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import { INVALID_NAME_ERROR } from 'constants/claim';
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
import * as PAGES from 'constants/pages';
import analytics from 'analytics';
const LANG_NONE = 'none';
const MAX_TAG_SELECT = 5;
type Props = {
uri: string,
claim: CollectionClaim,
balance: number,
disabled: boolean,
activeChannelClaim: ?ChannelClaim,
incognito: boolean,
// params
title: string,
amount: number,
thumbnailUrl: string,
description: string,
tags: Array<string>,
locations: Array<string>,
languages: Array<string>,
collectionId: string,
collection: Collection,
collectionClaimIds: Array<string>,
collectionUrls: Array<string>,
publishCollectionUpdate: (CollectionUpdateParams) => Promise<any>,
updatingCollection: boolean,
updateError: string,
publishCollection: (CollectionPublishParams, string) => Promise<any>,
createError: string,
creatingCollection: boolean,
clearCollectionErrors: () => void,
onDone: (string) => void,
openModal: (
id: string,
{ onUpdate: (string) => void, assetName: string, helpText: string, currentValue: string, title: string }
) => void,
};
function CollectionForm(props: Props) {
const {
uri, // collection uri
claim,
balance,
// publish params
amount,
title,
description,
thumbnailUrl,
tags,
locations,
languages = [],
// rest
onDone,
publishCollectionUpdate,
updateError,
updatingCollection,
publishCollection,
creatingCollection,
createError,
disabled,
activeChannelClaim,
incognito,
collectionId,
collection,
collectionUrls,
collectionClaimIds,
} = props;
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
let prefix = IS_WEB ? `${DOMAIN}/` : 'lbry://';
if (activeChannelName && !incognito) {
prefix += `${activeChannelName}/`;
}
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
const collectionName = (claim && claim.name) || (collection && collection.name);
const [nameError, setNameError] = React.useState(undefined);
const [bidError, setBidError] = React.useState('');
const [params, setParams]: [any, (any) => void] = React.useState(getCollectionParams());
const name = params.name;
const isNewCollection = !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 collectionClaimIdsString = JSON.stringify(collectionClaimIds);
function parseName(newName) {
let INVALID_URI_CHARS = new RegExp(regexInvalidURI, 'gu');
return newName.replace(INVALID_URI_CHARS, '-');
}
function setParam(paramObj) {
setParams({ ...params, ...paramObj });
}
function getCollectionParams() {
const collectionParams: {
thumbnail_url?: string,
name?: string,
description?: string,
title?: string,
bid: string,
languages?: ?Array<string>,
locations?: ?Array<string>,
tags?: ?Array<{ name: string }>,
claim_id?: string,
channel_id?: string,
claims: ?Array<string>,
} = {
thumbnail_url: thumbnailUrl,
description,
bid: String(amount || 0.001),
languages: languages || [],
locations: locations || [],
tags: tags
? tags.map((tag) => {
return { name: tag };
})
: [],
claims: collectionClaimIds,
};
collectionParams['name'] = parseName(collectionName);
if (activeChannelId) {
collectionParams['channel_id'] = activeChannelId;
}
if (!claim) {
collectionParams['title'] = collectionName;
}
if (claim) {
collectionParams['claim_id'] = claim.claim_id;
collectionParams['title'] = title;
}
return collectionParams;
}
React.useEffect(() => {
const collectionClaimIds = JSON.parse(collectionClaimIdsString);
setParams({ ...params, claims: collectionClaimIds });
}, [collectionClaimIdsString, setParams]);
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) {
setParams({ ...params, thumbnail_url: thumbnailUrl });
}
function handleSubmit() {
if (uri) {
publishCollectionUpdate(params).then((pendingClaim) => {
if (pendingClaim) {
const claimId = pendingClaim.claim_id;
analytics.apiLogPublish(pendingClaim);
onDone(claimId);
}
});
} else {
publishCollection(params, collectionId).then((pendingClaim) => {
if (pendingClaim) {
const claimId = pendingClaim.claim_id;
analytics.apiLogPublish(pendingClaim);
onDone(claimId);
}
});
}
}
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(() => {
if (incognito) {
const newParams = Object.assign({}, params);
delete newParams.channel_id;
setParams(newParams);
} else if (activeChannelId) {
setParams({ ...params, channel_id: activeChannelId });
}
}, [activeChannelId, incognito, setParams]);
const itemError = !params.claims.length ? __('Cannot publish empty collection') : '';
const submitError = nameError || bidError || itemError || updateError || createError;
return (
<>
<div className={classnames('main--contained', { 'card--disabled': disabled })}>
<Tabs>
<TabList className="tabs__list--channel-page">
<Tab>{__('General')}</Tab>
<Tab>{__('Items')}</Tab>
<Tab>{__('Credits')}</Tab>
<Tab>{__('Tags')}</Tab>
<Tab>{__('Other')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className={'card-stack'}>
<ChannelSelector disabled={disabled} />
<Card
body={
<>
<fieldset-group className="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="channel_name">{__('Channel')}</label>
</fieldset-section>
</fieldset-group>
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="channel_name">{__('Name')}</label>
<div className="form-field__prefix">{prefix}</div>
</fieldset-section>
<FormField
autoFocus={isNewCollection}
type="text"
name="channel_name"
placeholder={__('MyAwesomeCollection')}
value={params.name}
error={nameError}
disabled={!isNewCollection}
onChange={(e) => setParams({ ...params, name: e.target.value || '' })}
/>
</fieldset-group>
{!isNewCollection && (
<span className="form-field__help">{__('This field cannot be changed.')}</span>
)}
<FormField
type="text"
name="channel_title2"
label={__('Title')}
placeholder={__('My Awesome Collection')}
value={params.title}
onChange={(e) => setParams({ ...params, title: e.target.value })}
/>
<ThumbnailPicker
inline
thumbnailParam={params.thumbnail_url}
updateThumbnailParam={handleThumbnailChange}
/>
<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}
/>
</>
}
/>
</div>
</TabPanel>
<TabPanel>
<ClaimList
uris={collectionUrls}
collectionId={collectionId}
empty={__('This collection has no items.')}
/>
</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.bid}
error={bidError}
min="0.0"
disabled={false}
onChange={(event) =>
handleBidChange(parseFloat(event.target.value), amount, balance, setBidError, setParam)
}
placeholder={0.1}
helper={__('Increasing your deposit can help your channel be discovered more easily.')}
/>
}
/>
</TabPanel>
<TabPanel>
<Card
body={
<TagsSearch
suggestMature
disableAutoFocus
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
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>
{Object.keys(SUPPORTED_LANGUAGES).map((language) => (
<option key={language} value={language}>
{SUPPORTED_LANGUAGES[language]}
</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>
{Object.keys(SUPPORTED_LANGUAGES)
.filter((lang) => lang !== languageParam[0])
.map((language) => (
<option key={language} value={language}>
{SUPPORTED_LANGUAGES[language]}
</option>
))}
</FormField>
</>
}
/>
</TabPanel>
</TabPanels>
</Tabs>
<Card
className="card--after-tabs"
actions={
<>
<div className="section__actions">
<Button
button="primary"
disabled={creatingCollection || updatingCollection || nameError || bidError || !params.claims.length}
label={creatingCollection || updatingCollection ? __('Submitting') : __('Submit')}
onClick={handleSubmit}
/>
<Button button="link" label={__('Cancel')} onClick={() => onDone(collectionId)} />
</div>
{submitError ? (
<ErrorText>{submitError}</ErrorText>
) : (
<p className="help">
{__('After submitting, it will take a few minutes for your changes to be live for everyone.')}
</p>
)}
{!isNewCollection && (
<div className="section__actions">
<ClaimAbandonButton uri={uri} abandonActionCallback={() => replace(`/$/${PAGES.LIBRARY}`)} />
</div>
)}
</>
}
/>
</div>
</>
);
}
export default CollectionForm;

View file

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import { doCollectionEdit, makeSelectNameForCollectionId, doCollectionDelete } from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import CollectionMenuList from './view';
const select = (state, props) => {
return {
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
};
};
export default connect(select, {
doCollectionEdit,
doOpenModal,
doCollectionDelete,
})(CollectionMenuList);

View file

@ -0,0 +1,59 @@
// @flow
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import React from 'react';
import classnames from 'classnames';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import * as PAGES from 'constants/pages';
import { useHistory } from 'react-router';
type Props = {
inline?: boolean,
doOpenModal: (string, {}) => void,
collectionName?: string,
collectionId: string,
};
function ClaimMenuList(props: Props) {
const { inline = false, collectionId, collectionName, doOpenModal } = props;
const { push } = useHistory();
return (
<Menu>
<MenuButton
className={classnames('menu__button', { 'claim__menu-button': !inline, 'claim__menu-button--inline': inline })}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Icon size={20} icon={ICONS.MORE_VERTICAL} />
</MenuButton>
<MenuList className="menu__list">
{collectionId && collectionName && (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.VIEW} />
{__('Edit List')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => doOpenModal(MODALS.COLLECTION_DELETE, { collectionId })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete List')}
</div>
</MenuItem>
</>
)}
</MenuList>
</Menu>
);
}
export default ClaimMenuList;

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import {
makeSelectIsUriResolving,
makeSelectClaimIdForUri,
makeSelectClaimForClaimId,
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
makeSelectPendingCollectionForId,
makeSelectCountForCollectionId,
} from 'lbry-redux';
import CollectionPreviewOverlay from './view';
const select = (state, props) => {
const collectionId = props.collectionId || (props.uri && makeSelectClaimIdForUri(props.uri));
const claim = props.collectionId && makeSelectClaimForClaimId(props.collectionId)(state);
const collectionUri = props.uri || (claim && (claim.canonical_url || claim.permanent_url)) || null;
return {
collectionId,
uri: collectionUri,
collectionCount: makeSelectCountForCollectionId(collectionId)(state),
collectionName: makeSelectNameForCollectionId(collectionId)(state),
collectionItemUrls: makeSelectUrlsForCollectionId(collectionId)(state), // ForId || ForUri
pendingCollection: makeSelectPendingCollectionForId(collectionId)(state),
claim,
isResolvingUri: collectionUri && makeSelectIsUriResolving(collectionUri)(state),
};
};
export default connect(select)(CollectionPreviewOverlay);

View file

@ -0,0 +1,42 @@
// @flow
import React from 'react';
import { withRouter } from 'react-router-dom';
import FileThumbnail from 'component/fileThumbnail';
type Props = {
uri: string,
collectionId: string,
collectionName: string,
collectionCount: number,
editedCollection?: Collection,
pendingCollection?: Collection,
claim: ?Claim,
collectionItemUrls: Array<string>,
};
function CollectionPreviewTileOverlay(props: Props) {
const { collectionItemUrls } = props;
if (collectionItemUrls && collectionItemUrls.length > 0) {
return (
<div className="collection-preview__overlay-thumbs">
<div className="collection-preview__overlay-side" />
<div className="collection-preview__overlay-grid">
{collectionItemUrls &&
collectionItemUrls.map((item, index) => {
if (index < 2) {
return (
<div className="collection-preview__overlay-grid-items">
<FileThumbnail uri={item} key={item} />
</div>
);
}
})}
</div>
</div>
);
}
return null;
}
export default withRouter(CollectionPreviewTileOverlay);

View file

@ -0,0 +1,19 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
type Props = {
count: number,
};
export default function collectionCount(props: Props) {
const { count = 0 } = props;
return (
<div className={classnames('claim-preview__overlay-properties', 'claim-preview__overlay-properties--small')}>
<Icon icon={ICONS.STACK} />
<div>{count}</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
export default function collectionCount() {
return (
<div className={classnames('claim-preview__overlay-properties', 'claim-preview__overlay-properties--small')}>
<Icon icon={ICONS.LOCK} />
<div>{__('Private')}</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
import { connect } from 'react-redux';
import {
doResolveUri,
makeSelectIsUriResolving,
makeSelectThumbnailForUri,
makeSelectTitleForUri,
makeSelectChannelForClaimUri,
makeSelectClaimIsNsfw,
makeSelectClaimIdForUri,
makeSelectClaimForClaimId,
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
doLocalCollectionDelete,
doFetchItemsInCollection,
makeSelectEditedCollectionForId,
makeSelectPendingCollectionForId,
makeSelectCountForCollectionId,
makeSelectIsResolvingCollectionForId,
} from 'lbry-redux';
import { selectMutedChannels } from 'redux/selectors/blocked';
import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc';
import { selectShowMatureContent } from 'redux/selectors/settings';
import CollectionPreviewTile from './view';
const select = (state, props) => {
const collectionId = props.collectionId || (props.uri && makeSelectClaimIdForUri(props.uri));
const claim = props.collectionId && makeSelectClaimForClaimId(props.collectionId)(state);
const collectionUri = props.uri || (claim && (claim.canonical_url || claim.permanent_url)) || null;
return {
collectionId,
uri: collectionUri,
collectionCount: makeSelectCountForCollectionId(collectionId)(state),
collectionName: makeSelectNameForCollectionId(collectionId)(state),
collectionItemUrls: makeSelectUrlsForCollectionId(collectionId)(state), // ForId || ForUri
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
pendingCollection: makeSelectPendingCollectionForId(collectionId)(state),
claim,
isResolvingCollectionClaims: makeSelectIsResolvingCollectionForId(collectionId)(state),
channelClaim: collectionUri && makeSelectChannelForClaimUri(collectionUri)(state),
isResolvingUri: collectionUri && makeSelectIsUriResolving(collectionUri)(state),
thumbnail: collectionUri && makeSelectThumbnailForUri(collectionUri)(state),
title: collectionUri && makeSelectTitleForUri(collectionUri)(state),
blackListedOutpoints: selectBlackListedOutpoints(state),
filteredOutpoints: selectFilteredOutpoints(state),
blockedChannelUris: selectMutedChannels(state),
showMature: selectShowMatureContent(state),
isMature: makeSelectClaimIsNsfw(collectionUri)(state),
};
};
const perform = (dispatch) => ({
resolveUri: (uri) => dispatch(doResolveUri(uri)),
resolveCollectionItems: (options) => doFetchItemsInCollection(options),
deleteCollection: (id) => dispatch(doLocalCollectionDelete(id)),
});
export default connect(select, perform)(CollectionPreviewTile);

View file

@ -0,0 +1,174 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import { NavLink, useHistory } from 'react-router-dom';
import ClaimPreviewTile from 'component/claimPreviewTile';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
import TruncatedText from 'component/common/truncated-text';
import CollectionCount from './collectionCount';
import CollectionPrivate from './collectionPrivate';
import CollectionMenuList from 'component/collectionMenuList';
import { formatLbryUrlForWeb } from 'util/url';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
type Props = {
uri: string,
collectionId: string,
collectionName: string,
collectionCount: number,
editedCollection?: Collection,
pendingCollection?: Collection,
claim: ?Claim,
channelClaim: ?ChannelClaim,
collectionItemUrls: Array<string>,
resolveUri: (string) => void,
isResolvingUri: boolean,
thumbnail?: string,
title?: string,
placeholder: boolean,
blackListedOutpoints: Array<{
txid: string,
nout: number,
}>,
filteredOutpoints: Array<{
txid: string,
nout: number,
}>,
blockedChannelUris: Array<string>,
isMature?: boolean,
showMature: boolean,
collectionId: string,
deleteCollection: (string) => void,
resolveCollectionItems: (any) => void,
isResolvingCollectionClaims: boolean,
};
function CollectionPreviewTile(props: Props) {
const {
uri,
collectionId,
collectionName,
collectionCount,
isResolvingUri,
isResolvingCollectionClaims,
collectionItemUrls,
claim,
resolveCollectionItems,
} = props;
const { push } = useHistory();
const hasClaim = Boolean(claim);
React.useEffect(() => {
if (collectionId && hasClaim && resolveCollectionItems) {
resolveCollectionItems({ collectionId, page_size: 5 });
}
}, [collectionId, hasClaim]);
// const signingChannel = claim && claim.signing_channel;
let navigateUrl = formatLbryUrlForWeb(collectionItemUrls[0] || '/');
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
function handleClick(e) {
if (navigateUrl) {
push(navigateUrl);
}
}
const navLinkProps = {
to: navigateUrl,
onClick: (e) => e.stopPropagation(),
};
/* REMOVE IF WORKS
let shouldHide = false;
if (isMature && !showMature) {
// Unfortunately needed until this is resolved
// https://github.com/lbryio/lbry-sdk/issues/2785
shouldHide = true;
}
// This will be replaced once blocking is done at the wallet server level
if (claim && !shouldHide && blackListedOutpoints) {
shouldHide = blackListedOutpoints.some(
(outpoint) =>
(signingChannel && outpoint.txid === signingChannel.txid && outpoint.nout === signingChannel.nout) ||
(outpoint.txid === claim.txid && outpoint.nout === claim.nout)
);
}
// We're checking to see if the stream outpoint
// or signing channel outpoint is in the filter list
if (claim && !shouldHide && filteredOutpoints) {
shouldHide = filteredOutpoints.some(
(outpoint) =>
(signingChannel && outpoint.txid === signingChannel.txid && outpoint.nout === signingChannel.nout) ||
(outpoint.txid === claim.txid && outpoint.nout === claim.nout)
);
}
// block stream claims
if (claim && !shouldHide && blockedChannelUris.length && signingChannel) {
shouldHide = blockedChannelUris.some((blockedUri) => blockedUri === signingChannel.permanent_url);
}
// block channel claims if we can't control for them in claim search
// e.g. fetchRecommendedSubscriptions
if (shouldHide) {
return null;
}
*/
if (isResolvingUri || isResolvingCollectionClaims) {
return (
<li className={classnames('claim-preview--tile', {})}>
<div className="placeholder media__thumb" />
<div className="placeholder__wrapper">
<div className="placeholder claim-tile__title" />
<div className="placeholder claim-tile__info" />
</div>
</li>
);
}
if (uri) {
return <ClaimPreviewTile uri={uri} />;
}
return (
<li role="link" onClick={handleClick} className={'card claim-preview--tile'}>
<NavLink {...navLinkProps}>
<div className={classnames('media__thumb')}>
<React.Fragment>
<div className="claim-preview__collection-wrapper">
<CollectionPreviewOverlay collectionId={collectionId} />
</div>
<div className="claim-preview__claim-property-overlay">
<CollectionCount count={collectionCount} />
</div>
</React.Fragment>
</div>
</NavLink>
<NavLink {...navLinkProps}>
<h2 className="claim-tile__title">
<TruncatedText text={collectionName} lines={1} />
<CollectionMenuList collectionId={collectionId} />
</h2>
</NavLink>
<div>
<div className="claim-tile__info">
<React.Fragment>
<div className="claim-tile__about">
<CollectionPrivate />
</div>
</React.Fragment>
</div>
</div>
</li>
);
}
export default CollectionPreviewTile;

View file

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import {
doCollectionEdit,
makeSelectClaimForUri,
makeSelectCollectionForId,
makeSelectClaimIsPending,
makeSelectCollectionForIdHasClaimUrl,
} from 'lbry-redux';
import CollectionSelectItem from './view';
const select = (state, props) => {
return {
collection: makeSelectCollectionForId(props.collectionId)(state),
hasClaim: makeSelectCollectionForIdHasClaimUrl(props.collectionId, props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
collectionPending: makeSelectClaimIsPending(props.collectionId)(state),
};
};
const perform = (dispatch) => ({
editCollection: (id, params) => dispatch(doCollectionEdit(id, params)),
});
export default connect(select, perform)(CollectionSelectItem);

View file

@ -0,0 +1,59 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import { FormField } from 'component/common/form';
import Icon from 'component/common/icon';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
type Props = {
collection: Collection,
hasClaim: boolean,
category: string,
edited: boolean,
editCollection: (string, CollectionEditParams) => void,
claim: Claim,
collectionPending: Collection,
};
function CollectionSelectItem(props: Props) {
const { collection, hasClaim, category, editCollection, claim, collectionPending } = props;
const { name, id } = collection;
const handleChange = (e) => {
editCollection(id, { claims: [claim], remove: hasClaim });
};
let icon;
switch (category) {
case 'builtin':
icon = id === COLLECTIONS_CONSTS.WATCH_LATER_ID ? ICONS.TIME : ICONS.STACK;
break;
case 'published':
icon = ICONS.STACK;
break;
default:
// 'unpublished'
icon = ICONS.LOCK;
break;
}
return (
<div className={'collection-select__item'}>
<FormField
checked={hasClaim}
disabled={collectionPending}
icon={icon}
type="checkbox"
name={`select-${id}`}
onChange={handleChange} // edit the collection
label={
<span>
<Icon icon={icon} className={'icon-collection-select'} />
{`${name}`}
</span>
} // the collection name
/>
</div>
);
}
export default CollectionSelectItem;

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import {
selectBuiltinCollections,
selectMyPublishedPlaylistCollections,
selectMyUnpublishedCollections, // should probably distinguish types
// selectSavedCollections,
} from 'lbry-redux';
import CollectionsListMine from './view';
const select = (state) => ({
builtinCollections: selectBuiltinCollections(state),
publishedPlaylists: selectMyPublishedPlaylistCollections(state),
unpublishedCollections: selectMyUnpublishedCollections(state),
// savedCollections: selectSavedCollections(state),
});
export default connect(select)(CollectionsListMine);

View file

@ -0,0 +1,91 @@
// @flow
import React from 'react';
import CollectionPreviewTile from 'component/collectionPreviewTile';
import ClaimList from 'component/claimList';
import Button from 'component/button';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import * as PAGES from 'constants/pages';
type Props = {
builtinCollections: CollectionGroup,
publishedCollections: CollectionGroup,
publishedPlaylists: CollectionGroup,
unpublishedCollections: CollectionGroup,
// savedCollections: CollectionGroup,
};
export default function CollectionsListMine(props: Props) {
const {
builtinCollections,
publishedPlaylists,
unpublishedCollections,
// savedCollections, these are resolved on startup from sync'd claimIds or urls
} = props;
const builtinCollectionsList = (Object.values(builtinCollections || {}): any);
const unpublishedCollectionsList = (Object.keys(unpublishedCollections || {}): any);
const publishedList = (Object.keys(publishedPlaylists || {}): any);
const hasCollections = unpublishedCollectionsList.length || publishedList.length;
const watchLater = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID);
const favorites = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.FAVORITES_ID);
const builtin = [watchLater, favorites];
return (
<>
{builtin.map((list: Collection) => {
const { items: itemUrls } = list;
return (
<div className="claim-grid__wrapper" key={list.name}>
<>
{Boolean(itemUrls && itemUrls.length) && (
<>
<h1 className="claim-grid__header">
<Button
className="claim-grid__title"
button="link"
navigate={`/$/${PAGES.LIST}/${list.id}`}
label={list.name}
/>
</h1>
<ClaimList
tileLayout
key={list.name}
uris={itemUrls}
collectionId={list.id}
empty={__('Nothing in %collection_name%', { collection_name: list.name })}
/>
</>
)}
{!(itemUrls && itemUrls.length) && (
<h1 className="claim-grid__header claim-grid__title">
{__('%collection_name%', { collection_name: list.name })}{' '}
<div className="claim-grid__title--empty">(Empty)</div>
</h1>
)}
</>
</div>
);
})}
{Boolean(hasCollections) && (
<div className="claim-grid__wrapper">
<h1 className="claim-grid__header">
<span className="claim-grid__title">{__('Playlists')}</span>
</h1>
<>
<div className="claim-grid">
{unpublishedCollectionsList &&
unpublishedCollectionsList.length > 0 &&
unpublishedCollectionsList.map((key) => (
<CollectionPreviewTile tileLayout collectionId={key} key={key} />
))}
{publishedList &&
publishedList.length > 0 &&
publishedList.map((key) => <CollectionPreviewTile tileLayout collectionId={key} key={key} />)}
</div>
</>
</div>
)}
</>
);
}

View file

@ -82,7 +82,7 @@ export default function Card(props: Props) {
{subtitle && <div className="card__subtitle">{subtitle}</div>}
</div>
</div>
<div>
<div className="card__title-actions-container">
{titleActions && (
<div
className={classnames('card__title-actions', {

View file

@ -555,6 +555,13 @@ export const icons = {
<circle cx="12" cy="19" r="1" />
</g>
),
[ICONS.MORE]: buildIcon(
<g transform="rotate(90 12 12)">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
</g>
),
[ICONS.VALIDATED]: buildIcon(
<g>
<polyline points="20 6 9 17 4 12" />
@ -743,22 +750,6 @@ export const icons = {
viewBox: '0 0 60 60',
}
),
[ICONS.MORE]: buildIcon(
<g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 30C0 13.4315 13.4315 0 30 0C46.5685 0 60 13.4315 60 30C60 46.5685 46.5685 60 30 60C13.4315 60 0 46.5685 0 30Z"
fill="#eee"
/>
<circle cx="20" cy="30" r="2" stroke="black" fill="black" />
<circle cx="30" cy="30" r="2" stroke="black" fill="black" />
<circle cx="40" cy="30" r="2" stroke="black" fill="black" />
</g>,
{
viewBox: '0 0 60 60',
}
),
[ICONS.SHARE_LINK]: buildIcon(
<g>
<path
@ -1786,7 +1777,8 @@ export const icons = {
d="M-506.9,1532.1c1.8-1.8,2.9-4.3,2.9-7.1c0-2.6-1-4.9-2.5-6.7L-506.9,1532.1z"
/>
</g>
<rect id="XMLID_125_" x="0" y="0" fill="none" width="36" height="36" stroke="none" /> {/* }//fill="#FFFFFF" */}
<rect id="XMLID_125_" x="0" y="0" fill="none" width="36" height="36" stroke="none" />
{/* }//fill="#FFFFFF" */}
<linearGradient
id="XMLID_421_"
gradientUnits="userSpaceOnUse"
@ -2048,7 +2040,8 @@ export const icons = {
d="M-506.9,1532.1c1.8-1.8,2.9-4.3,2.9-7.1c0-2.6-1-4.9-2.5-6.7L-506.9,1532.1z"
/>
</g>
<rect id="XMLID_125_" x="0" y="0" fill="none" width="36" height="36" stroke="none" /> {/* }//fill="#FFFFFF" */}
<rect id="XMLID_125_" x="0" y="0" fill="none" width="36" height="36" stroke="none" />
{/* }//fill="#FFFFFF" */}
<linearGradient
id="XMLID_421_"
gradientUnits="userSpaceOnUse"
@ -2259,4 +2252,44 @@ export const icons = {
/>
</svg>
),
[ICONS.STACK]: (props: CustomProps) => (
<svg
{...props}
viewBox="0 0 24 24"
width={props.size || '18'}
height={props.size || '18'}
xmlns="http://www.w3.org/2000/svg"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke="currentColor"
fill="none"
>
<g transform="matrix(1,0,0,1,0,0)">
<path d="M22.91,6.953,12.7,1.672a1.543,1.543,0,0,0-1.416,0L1.076,6.953a.615.615,0,0,0,0,1.094l10.209,5.281a1.543,1.543,0,0,0,1.416,0L22.91,8.047a.616.616,0,0,0,0-1.094Z" />
<path d="M.758,12.75l10.527,5.078a1.543,1.543,0,0,0,1.416,0L23.258,12.75" />
<path d="M.758,17.25l10.527,5.078a1.543,1.543,0,0,0,1.416,0L23.258,17.25" />
</g>
</svg>
),
[ICONS.TIME]: (props: CustomProps) => (
<svg
{...props}
viewBox="0 0 24 24"
width={props.size || '18'}
height={props.size || '18'}
xmlns="http://www.w3.org/2000/svg"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke="currentColor"
fill="none"
>
<g transform="matrix(1,0,0,1,0,0)">
<path d="M1.500 12.000 A10.500 10.500 0 1 0 22.500 12.000 A10.500 10.500 0 1 0 1.500 12.000 Z" />
<path d="M12 12L12 8.25" />
<path d="M12 12L16.687 16.688" />
</g>
</svg>
),
};

View file

@ -71,7 +71,7 @@ class IconComponent extends React.PureComponent<Props> {
<Icon
title={tooltipText}
size={size || (sectionIcon ? 20 : 16)}
className={classnames(`icon icon--${icon}`, className)}
className={classnames(`icon icon--${icon}`, className, { 'color-override': iconColor })}
color={color}
{...rest}
/>

View file

@ -9,12 +9,14 @@ import {
makeSelectTagInClaimOrChannelForUri,
} from 'lbry-redux';
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import { makeSelectUserPropForProp } from 'redux/selectors/user';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { doSetPlayingUri } from 'redux/actions/content';
import { doToast } from 'redux/actions/notifications';
import { doOpenModal, doSetActiveChannel, doSetIncognito } from 'redux/actions/app';
import fs from 'fs';
import FileActions from './view';
import * as USER from 'constants/user';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
const select = (state, props) => ({
@ -26,6 +28,7 @@ const select = (state, props) => ({
myChannels: selectMyChannelClaims(state),
isLivestreamClaim: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
reactionsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
hasExperimentalUi: makeSelectUserPropForProp(USER.EXPERIMENTAL_UI)(state),
});
const perform = (dispatch) => ({

View file

@ -10,6 +10,7 @@ import { buildURI } from 'lbry-redux';
import * as RENDER_MODES from 'constants/file_render_modes';
import { useIsMobile } from 'effects/use-screensize';
import ClaimSupportButton from 'component/claimSupportButton';
import ClaimCollectionAddButton from 'component/claimCollectionAddButton';
import { useHistory } from 'react-router';
import FileReactions from 'component/fileReactions';
@ -27,6 +28,7 @@ type Props = {
clearPlayingUri: () => void,
isLivestreamClaim: boolean,
reactionsDisabled: boolean,
hasExperimentalUi: boolean,
};
function FileActions(props: Props) {
@ -44,6 +46,7 @@ function FileActions(props: Props) {
doToast,
isLivestreamClaim,
reactionsDisabled,
hasExperimentalUi,
} = props;
const {
push,
@ -86,6 +89,7 @@ function FileActions(props: Props) {
<>
{ENABLE_FILE_REACTIONS && !reactionsDisabled && <FileReactions uri={uri} />}
<ClaimSupportButton uri={uri} fileAction />
{hasExperimentalUi && <ClaimCollectionAddButton uri={uri} fileAction />}
<Button
button="alt"
className="button--file-action"

View file

@ -20,10 +20,11 @@ type Props = {
pendingAmount: number,
doOpenModal: (id: string, {}) => void,
claimIsMine: boolean,
expandOverride: boolean,
};
function FileDescription(props: Props) {
const { uri, claim, metadata, pendingAmount, doOpenModal, claimIsMine } = props;
const { uri, claim, metadata, pendingAmount, doOpenModal, claimIsMine, expandOverride } = props;
const [expanded, setExpanded] = React.useState(false);
const [showCreditDetails, setShowCreditDetails] = React.useState(false);
const amount = parseFloat(claim.amount) + parseFloat(pendingAmount || claim.meta.support_amount);
@ -40,9 +41,9 @@ function FileDescription(props: Props) {
<div>
<div
className={classnames({
'media__info-text--contracted': !expanded,
'media__info-text--contracted': !expanded && !expandOverride,
'media__info-text--expanded': expanded,
'media__info-text--fade': !expanded,
'media__info-text--fade': !expanded && !expandOverride,
})}
>
{description && <MarkdownPreview className="markdown-preview--description" content={description} simpleLinks />}
@ -51,10 +52,14 @@ function FileDescription(props: Props) {
</div>
<div className="card__bottom-actions">
{expanded ? (
<Button button="link" label={__('Less')} onClick={() => setExpanded(!expanded)} />
) : (
<Button button="link" label={__('More')} onClick={() => setExpanded(!expanded)} />
{!expandOverride && (
<>
{expanded ? (
<Button button="link" label={__('Less')} onClick={() => setExpanded(!expanded)} />
) : (
<Button button="link" label={__('More')} onClick={() => setExpanded(!expanded)} />
)}
</>
)}
<div className="section__actions--no-margin">

View file

@ -9,7 +9,7 @@ type Props = {
claim: StreamClaim,
fileInfo: FileListItem,
metadata: StreamMetadata,
openFolder: string => void,
openFolder: (string) => void,
contentType: string,
user: ?any,
};
@ -58,7 +58,7 @@ class FileDetails extends PureComponent<Props> {
</div>
)}
{languages && (
{mediaType && (
<div className="media__details">
<span>{__('Media Type')}</span>
<span>{mediaType}</span>

View file

@ -1,44 +0,0 @@
// @flow
import type { Node } from 'react';
import * as ICONS from 'constants/icons';
import * as React from 'react';
import classnames from 'classnames';
import Icon from 'component/common/icon';
import FilePrice from 'component/filePrice';
import VideoDuration from 'component/videoDuration';
import FileType from 'component/fileType';
type Props = {
uri: string,
downloaded: boolean,
claimIsMine: boolean,
isSubscribed: boolean,
small: boolean,
claim: Claim,
properties?: (Claim) => ?Node,
};
export default function FileProperties(props: Props) {
const { uri, downloaded, claimIsMine, isSubscribed, small = false, properties, claim } = props;
return (
<div
className={classnames('file-properties', {
'file-properties--small': small,
})}
>
{typeof properties === 'function' ? (
properties(claim)
) : (
<>
<VideoDuration uri={uri} />
<FileType uri={uri} />
{isSubscribed && <Icon tooltip icon={ICONS.SUBSCRIBE} />}
{!claimIsMine && downloaded && <Icon tooltip icon={ICONS.LIBRARY} />}
<FilePrice hideFree uri={uri} />
</>
)}
</div>
);
}

View file

@ -8,9 +8,9 @@ import NudgeFloating from 'component/nudgeFloating';
type Props = {
claim: StreamClaim,
doFetchReactions: string => void,
doReactionLike: string => void,
doReactionDislike: string => void,
doFetchReactions: (string) => void,
doReactionLike: (string) => void,
doReactionDislike: (string) => void,
uri: string,
likeCount: number,
dislikeCount: number,
@ -21,7 +21,7 @@ function FileReactions(props: Props) {
const { claim, uri, doFetchReactions, doReactionLike, doReactionDislike, likeCount, dislikeCount } = props;
const claimId = claim && claim.claim_id;
const channel = claim && claim.signing_channel && claim.signing_channel.name;
const isCollection = claim && claim.value_type === 'collection'; // hack because nudge gets cut off by card on cols.
React.useEffect(() => {
if (claimId) {
doFetchReactions(claimId);
@ -30,7 +30,7 @@ function FileReactions(props: Props) {
return (
<>
{channel && (
{channel && !isCollection && (
<NudgeFloating
name="nudge:support-acknowledge"
text={__('Let %channel% know you enjoyed this!', { channel })}

View file

@ -39,11 +39,11 @@ const select = (state, props) => ({
authenticated: selectUserVerifiedEmail(state),
});
const perform = dispatch => ({
play: uri => {
const perform = (dispatch) => ({
play: (uri) => {
dispatch(doSetPrimaryUri(uri));
dispatch(doSetPlayingUri({ uri }));
dispatch(doPlayUri(uri, undefined, undefined, fileInfo => dispatch(doAnaltyicsPurchaseEvent(fileInfo))));
dispatch(doPlayUri(uri, undefined, undefined, (fileInfo) => dispatch(doAnaltyicsPurchaseEvent(fileInfo))));
},
});

View file

@ -52,6 +52,19 @@ function FileThumbnail(props: Props) {
const thumnailUrl = url ? url.replace(/'/g, "\\'") : '';
if (!hasResolvedClaim) {
return (
<div
ref={thumbnailRef}
data-background-image={thumnailUrl}
className={classnames('media__thumb', className, {
'media__thumb--resolving': !hasResolvedClaim,
})}
>
{children}
</div>
);
}
return (
<div
ref={thumbnailRef}

View file

@ -4,7 +4,7 @@ import { normalizeURI } from 'lbry-redux';
import FilePrice from 'component/filePrice';
import ClaimInsufficientCredits from 'component/claimInsufficientCredits';
import FileSubtitle from 'component/fileSubtitle';
import FileAuthor from 'component/fileAuthor';
import ClaimAuthor from 'component/claimAuthor';
import Card from 'component/common/card';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
@ -81,7 +81,7 @@ function FileTitleSection(props: Props) {
</div>
) : (
<div className="section">
<FileAuthor uri={uri} />
<ClaimAuthor uri={uri} />
<FileDescription uri={uri} />
</div>
)

View file

@ -2,24 +2,27 @@
import * as ICONS from 'constants/icons';
import React from 'react';
import Icon from 'component/common/icon';
import * as COL from 'constants/collections';
type Props = {
uri: string,
mediaType: string,
isLivestream: boolean,
small: boolean,
};
function FileType(props: Props) {
const { mediaType, isLivestream } = props;
const { mediaType, isLivestream, small } = props;
const size = small ? COL.ICON_SIZE : undefined;
if (mediaType === 'image') {
return <Icon icon={ICONS.IMAGE} />;
return <Icon size={size} icon={ICONS.IMAGE} />;
} else if (mediaType === 'audio') {
return <Icon icon={ICONS.AUDIO} />;
return <Icon size={size} icon={ICONS.AUDIO} />;
} else if (mediaType === 'video' || isLivestream) {
return <Icon icon={ICONS.VIDEO} />;
return <Icon size={size} icon={ICONS.VIDEO} />;
} else if (mediaType === 'text') {
return <Icon icon={ICONS.TEXT} />;
return <Icon size={size} icon={ICONS.TEXT} />;
}
return <Icon icon={ICONS.DOWNLOADABLE} />;

View file

@ -10,7 +10,7 @@ import { doUserInviteNew } from 'redux/actions/user';
import { selectMyChannelClaims, selectFetchingMyChannels, doFetchChannelListMine } from 'lbry-redux';
import InviteNew from './view';
const select = state => ({
const select = (state) => ({
errorMessage: selectUserInviteNewErrorMessage(state),
invitesRemaining: selectUserInvitesRemaining(state),
referralLink: selectUserInviteReferralLink(state),
@ -20,8 +20,8 @@ const select = state => ({
fetchingChannels: selectFetchingMyChannels(state),
});
const perform = dispatch => ({
inviteNew: email => dispatch(doUserInviteNew(email)),
const perform = (dispatch) => ({
inviteNew: (email) => dispatch(doUserInviteNew(email)),
fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
});

View file

@ -3,8 +3,8 @@ import * as React from 'react';
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import { formatCredits } from 'lbry-redux';
import FileAuthor from 'component/fileAuthor';
import FileDetails from 'component/fileDetails';
import ClaimAuthor from 'component/claimAuthor';
import FileTitle from 'component/fileTitle';
import FileActions from 'component/fileActions';
import FileRenderInitiator from 'component/fileRenderInitiator';
@ -102,7 +102,7 @@ function PostViewer(props: Props) {
</div>
)}
<FileAuthor uri={uri} />
<ClaimAuthor uri={uri} />
<FileRenderInitiator uri={uri} />
<FileRenderInline uri={uri} />

View file

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import {
makeSelectFilePartlyDownloaded,
makeSelectClaimIsMine,
makeSelectClaimForUri,
makeSelectEditedCollectionForId,
} from 'lbry-redux';
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import PreviewOverlayProperties from './view';
const select = (state, props) => {
const claim = makeSelectClaimForUri(props.uri)(state);
const claimId = claim && claim.claim_id;
return {
claim,
editedCollection: makeSelectEditedCollectionForId(claimId)(state),
downloaded: makeSelectFilePartlyDownloaded(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
};
};
export default connect(select, null)(PreviewOverlayProperties);

View file

@ -0,0 +1,75 @@
// @flow
import type { Node } from 'react';
import * as ICONS from 'constants/icons';
import * as React from 'react';
import classnames from 'classnames';
import Icon from 'component/common/icon';
import FilePrice from 'component/filePrice';
import VideoDuration from 'component/videoDuration';
import FileType from 'component/fileType';
import ClaimType from 'component/claimType';
import * as COL from 'constants/collections';
type Props = {
uri: string,
downloaded: boolean,
claimIsMine: boolean,
isSubscribed: boolean,
small: boolean,
claim: Claim,
properties?: (Claim) => ?Node,
iconOnly: boolean,
editedCollection: Collection,
};
export default function PreviewOverlayProperties(props: Props) {
const {
uri,
downloaded,
claimIsMine,
isSubscribed,
small = false,
properties,
claim,
iconOnly,
editedCollection,
} = props;
const isCollection = claim && claim.value_type === 'collection';
// $FlowFixMe
const claimLength = claim && claim.value.claims && claim.value.claims.length;
const claimCount = editedCollection ? editedCollection.items.length : claimLength;
const isStream = claim && claim.value_type === 'stream';
const size = small ? COL.ICON_SIZE : undefined;
return (
<div
className={classnames('claim-preview__overlay-properties', {
'.claim-preview__overlay-properties--small': small,
})}
>
{typeof properties === 'function' ? (
properties(claim)
) : (
<>
{!isStream && <ClaimType uri={uri} small={small} />}
{editedCollection && (
<Icon
customTooltipText={__('Unpublished Edits')}
tooltip
iconColor="red"
size={size}
icon={ICONS.PUBLISH}
/>
)}
{isCollection && claim && !iconOnly && <div>{claimCount}</div>}
{!iconOnly && isStream && <VideoDuration uri={uri} />}
{isStream && <FileType uri={uri} small={small} />}
{isSubscribed && <Icon tooltip size={size} icon={ICONS.SUBSCRIBE} />}
{!claimIsMine && downloaded && <Icon size={size} tooltip icon={ICONS.LIBRARY} />}
<FilePrice hideFree uri={uri} />
</>
)}
</div>
);
}

View file

@ -24,6 +24,7 @@ import RewardsPage from 'page/rewards';
import FileListPublished from 'page/fileListPublished';
import InvitePage from 'page/invite';
import SearchPage from 'page/search';
import ListsPage from 'page/lists';
import LibraryPage from 'page/library';
import WalletPage from 'page/wallet';
import TagsFollowingPage from 'page/tagsFollowing';
@ -55,6 +56,7 @@ import SwapPage from 'page/swap';
import NotificationsPage from 'page/notifications';
import SignInWalletPasswordPage from 'page/signInWalletPassword';
import YoutubeSyncPage from 'page/youtubeSync';
import CollectionPage from 'page/collection';
import { LINKED_COMMENT_QUERY_PARAM } from 'constants/comment';
import { parseURI, isURIValid } from 'lbry-redux';
@ -256,6 +258,7 @@ function AppRouter(props: Props) {
<Route path={`/$/${PAGES.INVITE}/:referrer`} exact component={InvitedPage} />
<Route path={`/$/${PAGES.CHECKOUT}`} exact component={CheckoutPage} />
<Route path={`/$/${PAGES.REPORT_CONTENT}`} exact component={ReportContentPage} />
<Route {...props} path={`/$/${PAGES.LIST}/:collectionId`} component={CollectionPage} />
<PrivateRoute {...props} exact path={`/$/${PAGES.YOUTUBE_SYNC}`} component={YoutubeSyncPage} />
<PrivateRoute {...props} exact path={`/$/${PAGES.TAGS_FOLLOWING}`} component={TagsFollowingPage} />
@ -282,6 +285,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS}`} exact component={RewardsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS_VERIFY}`} component={RewardsVerifyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIBRARY}`} component={LibraryPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_CREATOR}`} component={SettingsCreatorPage} />

View file

@ -8,6 +8,7 @@ import Button from 'component/button';
import Card from 'component/common/card';
import { generateThumbnailName } from 'util/generate-thumbnail-name';
import usePersistedState from 'effects/use-persisted-state';
import classnames from 'classnames';
const accept = '.png, .jpg, .jpeg, .gif';
const SPEECH_READY = 'READY';
@ -16,14 +17,15 @@ const SPEECH_UPLOADING = 'UPLOADING';
type Props = {
assetName: string,
currentValue: ?string,
onUpdate: string => void,
onUpdate: (string) => void,
recommended: string,
title: string,
onDone?: () => void,
inline?: boolean,
};
function SelectAsset(props: Props) {
const { onUpdate, onDone, assetName, recommended, title } = props;
const { onUpdate, onDone, assetName, recommended, title, inline } = props;
const [pathSelected, setPathSelected] = React.useState('');
const [fileSelected, setFileSelected] = React.useState<any>(null);
const [uploadStatus, setUploadStatus] = React.useState(SPEECH_READY);
@ -37,12 +39,19 @@ function SelectAsset(props: Props) {
}
}, [pathSelected, fileSelected]);
function handleToggleMode(useUrl) {
setPathSelected('');
setFileSelected(null);
setUrl('');
setUseUrl(useUrl);
}
function doUploadAsset() {
const uploadError = (error = '') => {
setError(error);
};
const onSuccess = thumbnailUrl => {
const onSuccess = (thumbnailUrl) => {
setUploadStatus(SPEECH_READY);
onUpdate(thumbnailUrl);
@ -62,9 +71,9 @@ function SelectAsset(props: Props) {
method: 'POST',
body: data,
})
.then(response => response.json())
.then(json => (json.success ? onSuccess(`${json.data.serveUrl}`) : uploadError(json.message)))
.catch(err => {
.then((response) => response.json())
.then((json) => (json.success ? onSuccess(`${json.data.serveUrl}`) : uploadError(json.message)))
.catch((err) => {
uploadError(err.message);
setUploadStatus(SPEECH_READY);
});
@ -72,66 +81,90 @@ function SelectAsset(props: Props) {
// Note for translators: e.g. "Thumbnail (1:1)"
const label = __('%image_type% %recommended_ratio%', { image_type: assetName, recommended_ratio: recommended });
const selectFileLabel = __('Select File');
const selectedLabel = pathSelected ? __('URL Selected') : __('File Selected');
let fileSelectorLabel;
if (uploadStatus === SPEECH_UPLOADING) {
fileSelectorLabel = __('Uploading...');
} else {
// Include the same label/recommendation for both 'URL' and 'UPLOAD'.
fileSelectorLabel = __('%label% • File to upload', { label: label });
fileSelectorLabel = __('%label% • %status%', {
label: label,
status: fileSelected || pathSelected ? selectedLabel : selectFileLabel,
});
}
const formBody = (
<>
<div className={'section__header--actions'}>
<div>
<Button
button="alt"
className={classnames('button-toggle', {
'button-toggle--active': useUrl, // disable on upload status
})}
label={__('Url')}
onClick={() => {
handleToggleMode(true);
}}
/>
<Button
button="alt"
className={classnames('button-toggle', {
'button-toggle--active': !useUrl, // disable on upload status
})}
label={__('Upload')}
onClick={() => {
handleToggleMode(false);
}}
/>
</div>
</div>
<fieldset-section>
{error && <div className="error__text">{error}</div>}
{useUrl ? (
<FormField
autoFocus
type={'text'}
name={'thumbnail'}
label={label}
placeholder={`https://example.com/image.png`}
value={url}
onChange={(e) => {
setUrl(e.target.value);
onUpdate(e.target.value);
}}
/>
) : (
<FileSelector
autoFocus
disabled={uploadStatus === SPEECH_UPLOADING}
label={fileSelectorLabel}
name="assetSelector"
currentPath={pathSelected}
onFileChosen={(file) => {
if (file.name) {
setFileSelected(file);
// what why? why not target=WEB this?
// file.path is undefined in web but available in electron
setPathSelected(file.name || file.path);
}
}}
accept={accept}
/>
)}
</fieldset-section>
</>
);
if (inline) {
return <fieldset-section>{formBody}</fieldset-section>;
}
return (
<Card
title={title || __('Choose image')}
actions={
<Form onSubmit={onDone}>
{error && <div className="error__text">{error}</div>}
{useUrl ? (
<FormField
autoFocus
type={'text'}
name={'thumbnail'}
label={label}
placeholder={'https://example.com/image.png'}
value={url}
onChange={e => {
setUrl(e.target.value);
onUpdate(e.target.value);
}}
/>
) : (
<FileSelector
autoFocus
disabled={uploadStatus === SPEECH_UPLOADING}
label={fileSelectorLabel}
name="assetSelector"
currentPath={pathSelected}
onFileChosen={file => {
if (file.name) {
setFileSelected(file);
// file.path is undefined in web but available in electron
setPathSelected(file.name || file.path);
}
}}
accept={accept}
/>
)}
<div className="section__actions">
{onDone && (
<Button button="primary" type="submit" label={__('Done')} disabled={uploadStatus === SPEECH_UPLOADING} />
)}
<FormField
name="toggle-upload"
type="checkbox"
label={__('Use a URL')}
checked={useUrl}
onChange={() => setUseUrl(!useUrl)}
/>
</div>
</Form>
}
title={title || __('Choose %asset%', { asset: __(`${assetName}`) })}
actions={<Form onSubmit={onDone}>{formBody}</Form>}
/>
);
}

View file

@ -2,10 +2,11 @@ import { connect } from 'react-redux';
import { selectSubscriptions } from 'redux/selectors/subscriptions';
import { selectPurchaseUriSuccess, doClearPurchasedUriSuccess } from 'lbry-redux';
import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user';
import { selectUserVerifiedEmail, selectUser, makeSelectUserPropForProp } from 'redux/selectors/user';
import { selectHomepageData, selectLanguage } from 'redux/selectors/settings';
import { doSignOut } from 'redux/actions/app';
import { selectUnseenNotificationCount } from 'redux/selectors/notifications';
import * as USER from 'constants/user';
import SideNavigation from './view';
@ -18,6 +19,7 @@ const select = (state) => ({
unseenCount: selectUnseenNotificationCount(state),
user: selectUser(state),
homepageData: selectHomepageData(state),
hasExperimentalUi: makeSelectUserPropForProp(USER.EXPERIMENTAL_UI)(state),
});
export default connect(select, {

View file

@ -46,6 +46,7 @@ type Props = {
doClearPurchasedUriSuccess: () => void,
user: ?User,
homepageData: any,
hasExperimentalUi: boolean,
};
type SideNavLink = {
@ -73,6 +74,7 @@ function SideNavigation(props: Props) {
homepageData,
user,
followedTags,
hasExperimentalUi,
} = props;
const { EXTRA_SIDEBAR_LINKS } = homepageData;
@ -219,15 +221,29 @@ function SideNavigation(props: Props) {
SIDE_LINKS.push(HOME);
SIDE_LINKS.push(RECENT_FROM_FOLLOWING);
if (!SIMPLE_SITE && hasExperimentalUi) {
FULL_LINKS.push({
title: 'Lists',
link: `/$/${PAGES.LISTS}`,
icon: ICONS.STACK,
hideForUnauth: true,
});
}
if (!SIMPLE_SITE) {
SIDE_LINKS.push(...FULL_LINKS);
} else if (SIMPLE_SITE && hasExperimentalUi) {
SIDE_LINKS.push({
title: 'Lists',
link: `/$/${PAGES.LISTS}`,
icon: ICONS.STACK,
hideForUnauth: true,
});
}
if (EXTRA_SIDEBAR_LINKS) {
SIDE_LINKS.push(...EXTRA_SIDEBAR_LINKS);
}
if (!SIMPLE_SITE) {
SIDE_LINKS.push(...FULL_LINKS);
}
const [pulseLibrary, setPulseLibrary] = React.useState(false);
const isPersonalized = !IS_WEB || isAuthenticated;
const isAbsolute = isOnFilePage || isMediumScreen;

View file

@ -83,7 +83,7 @@ function SocialShare(props: Props) {
name="share_start_at"
value={startTime}
disabled={!includeStartTime}
onChange={event => setStartTime(event.target.value)}
onChange={(event) => setStartTime(event.target.value)}
/>
</div>
)}
@ -166,7 +166,7 @@ function SocialShare(props: Props) {
{showClaimLinks && (
<div className="section">
<CopyableText label={__('LBRY URL')} copyable={`lbry://${lbryUrl}`} />
{!isChannel && <CopyableText label={__('Download Link')} copyable={downloadUrl} />}
{Boolean(isStream) && <CopyableText label={__('Download Link')} copyable={downloadUrl} />}
</div>
)}
</React.Fragment>

View file

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import ThumbnailPicker from './view';
import { makeSelectThumbnailForUri } from 'lbry-redux';
const select = (state, props) => ({
thumbnailForUri: makeSelectThumbnailForUri(props.uri)(state),
});
// const perform = dispatch => ({
// });
export default connect(select)(ThumbnailPicker);

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,116 @@
// @flow
import * as React from 'react';
import Card from 'component/common/card';
import ThumbnailMissingImage from './thumbnail-missing.png';
import SelectAsset from 'component/selectAsset';
type Props = {
// uploadThumbnailStatus: string,
thumbnailParam: string,
thumbnailForUri: string,
updateThumbnailParam: (string) => void,
inline?: boolean,
};
const ThumbnailPicker = (props: Props) => {
const { thumbnailForUri, thumbnailParam, updateThumbnailParam, inline } = props;
// uploadThumbnailStatus
// {status === THUMBNAIL_STATUSES.API_DOWN || status === THUMBNAIL_STATUSES.MANUAL ? (
// {status === THUMBNAIL_STATUSES.READY && (
// {status === THUMBNAIL_STATUSES.COMPLETE && thumbnail && (
const [thumbError, setThumbError] = React.useState(false); // possibly existing URL
const updateThumb = (thumb: string) => {
setThumbError(false);
updateThumbnailParam(thumb);
};
if (inline) {
return (
<fieldset-section>
<label>{__('Thumbnail')}</label>
<div className="column">
{thumbError && (
<div
className="column__item thumbnail-picker__preview"
style={{ backgroundImage: `url(${ThumbnailMissingImage})` }}
/>
)}
{!thumbError && (
<div
className="column__item thumbnail-picker__preview"
style={{ backgroundImage: `url(${thumbnailParam || thumbnailForUri})` }}
>
<img
style={{ display: 'none' }}
src={thumbnailParam || thumbnailForUri}
alt={__('Thumbnail Preview')}
onError={() => {
if (thumbnailParam) {
setThumbError(true);
}
}}
/>
</div>
)}
<div className="column__item">
{/* if upload */}
<SelectAsset
inline
onUpdate={updateThumb}
currentValue={thumbnailParam}
assetName={'Image'}
recommended={'(16:9)'}
/>
</div>
</div>
</fieldset-section>
);
}
return (
<div>
<Card
body={
<div className="column">
{thumbError && (
<div
className="column__item thumbnail-preview"
style={{ backgroundImage: `url(${ThumbnailMissingImage})` }}
/>
)}
{!thumbError && (
<div
className="column__item thumbnail-preview"
style={{ backgroundImage: `url(${thumbnailParam || thumbnailForUri})` }}
>
<img
style={{ display: 'none' }}
src={thumbnailParam || thumbnailForUri}
alt={__('Thumbnail Preview')}
onError={() => {
if (thumbnailParam) {
setThumbError(true);
}
}}
/>
</div>
)}
<div className="column__item">
{/* if upload */}
<SelectAsset
inline
onUpdate={updateThumb}
currentValue={thumbnailParam}
assetName={'Thumbnail'}
recommended={'(16:9)'}
/>
</div>
</div>
}
/>
</div>
);
};
export default ThumbnailPicker;

View file

@ -15,7 +15,7 @@ type Props = {
function TransactionListTable(props: Props) {
const { emptyMessage, rewards, loading, txos } = props;
const REVOCABLE_TYPES = ['channel', 'stream', 'repost', 'support', 'claim'];
const REVOCABLE_TYPES = ['channel', 'stream', 'repost', 'support', 'claim', 'collection'];
function revokeClaim(tx: any, cb: (string) => void) {
props.openModal(MODALS.CONFIRM_CLAIM_REVOKE, { tx, cb });
}

View file

@ -4,6 +4,8 @@ import classnames from 'classnames';
import { ComboboxOption } from '@reach/combobox';
import FileThumbnail from 'component/fileThumbnail';
import ChannelThumbnail from 'component/channelThumbnail';
import FileProperties from 'component/previewOverlayProperties';
import ClaimProperties from 'component/claimProperties';
type Props = {
claim: ?Claim,
@ -18,6 +20,7 @@ export default function WunderbarSuggestion(props: Props) {
}
const isChannel = claim.value_type === 'channel';
const isCollection = claim.value_type === 'collection';
return (
<ComboboxOption value={uri}>
@ -26,7 +29,23 @@ export default function WunderbarSuggestion(props: Props) {
'wunderbar__suggestion--channel': isChannel,
})}
>
{isChannel ? <ChannelThumbnail uri={uri} /> : <FileThumbnail uri={uri} />}
{isChannel && <ChannelThumbnail uri={uri} />}
{!isChannel && (
<FileThumbnail uri={uri}>
{/* @if TARGET='app' */}
{!isCollection && (
<div className="claim-preview__file-property-overlay">
<FileProperties uri={uri} small iconOnly />
</div>
)}
{/* @endif */}
{isCollection && (
<div className="claim-preview__claim-property-overlay">
<ClaimProperties uri={uri} small iconOnly />
</div>
)}
</FileThumbnail>
)}
<span className="wunderbar__suggestion-label">
<div className="wunderbar__suggestion-title">{claim.value.title}</div>
<div className="wunderbar__suggestion-name">

View file

@ -51,8 +51,9 @@ export const CLAIM_TYPE = 'claim_type';
export const CLAIM_CHANNEL = 'channel';
export const CLAIM_STREAM = 'stream';
export const CLAIM_REPOST = 'repost';
export const CLAIM_TYPES = [CLAIM_CHANNEL, CLAIM_REPOST, CLAIM_STREAM];
export const CLAIM_COLLECTION = 'collection';
export const CLAIM_TYPES = [CLAIM_CHANNEL, CLAIM_REPOST, CLAIM_STREAM, CLAIM_COLLECTION];
export const CONTENT_ALL = 'all';
export const CONTENT_TYPES = [CONTENT_ALL, CLAIM_CHANNEL, CLAIM_REPOST, ...FILE_TYPES];
export const CONTENT_TYPES = [CONTENT_ALL, CLAIM_CHANNEL, CLAIM_REPOST, CLAIM_COLLECTION, ...FILE_TYPES];
export const KEYS = [ORDER_BY_KEY, TAGS_KEY, FRESH_KEY, CONTENT_KEY, DURATION_KEY];

View file

@ -0,0 +1,2 @@
// in repo constants for collections ui
export const ICON_SIZE = 12;

View file

@ -158,3 +158,5 @@ export const MIND_BLOWN = 'MindBlown';
export const LIVESTREAM = 'Livestream';
export const LIVESTREAM_SOLID = 'LivestreamSolid';
export const LIVESTREAM_MONOCHROME = 'LivestreamMono';
export const STACK = 'stack';
export const TIME = 'time';

View file

@ -43,3 +43,5 @@ export const IMAGE_UPLOAD = 'image_upload';
export const MOBILE_SEARCH = 'mobile_search';
export const VIEW_IMAGE = 'view_image';
export const CONFIRM_REMOVE_BTC_SWAP_ADDRESS = 'confirm_remove_btc_swap_address';
export const COLLECTION_ADD = 'collection_add';
export const COLLECTION_DELETE = 'collection_delete';

View file

@ -24,6 +24,7 @@ exports.WILD_WEST = 'wildwest';
exports.HOME = 'home';
exports.HELP = 'help';
exports.LIBRARY = 'library';
exports.LISTS = 'lists';
exports.INVITE = 'invite';
exports.DEPRECATED__PUBLISH = 'publish';
exports.DEPRECATED__PUBLISHED = 'published';
@ -70,3 +71,4 @@ exports.YOUTUBE_SYNC = 'youtube';
exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live';
exports.GENERAL = 'general';
exports.LIST = 'list';

26
ui/constants/user.js Normal file
View file

@ -0,0 +1,26 @@
export const ID = 'id';
export const LANGUAGE = 'language';
export const GIVEN_NAME = 'given_name';
export const FAMILY_NAME = 'family_name';
export const CREATED_AT = 'created_at';
export const UPDATED_AT = 'updated_at';
export const INVITED_BY_ID = 'invited_by_id';
export const INVITED_AT = 'invited_at';
export const INVITES_REMANING = 'invites_remaining';
export const INVITE_REWARD_CLAIMED = 'invite_reward_claimed';
export const IS_EMAIL_ENABLED = 'is_email_enabled';
export const PUBLISH_ID = 'publish_id';
export const COUNTRY = 'country';
export const IS_ODYSEE_USER = 'is_odysee_user';
export const LOCATION = 'location';
export const YOUTUBE_CHANNELS = 'youtube_channels';
export const PRIMARY_EMAIL = 'primary_email';
export const PASSWORD_SET = 'password_set';
export const LATEST_CLAIMED_EMAIL = 'latest_claimed_email';
export const HAS_VERIFIED_EMAIL = 'has_verified_email';
export const IS_IDENTITY_VERIFIED = 'is_identity_verified';
export const IS_REWARD_APPROVED = 'is_reward_approved';
export const ODYSEE_LIVE_ENABLED = 'odysee_live_enabled';
export const ODYSEE_LIVE_DISABLED = 'odysee_live_disabled';
export const GLOBAL_MOD = 'global_mod';
export const EXPERIMENTAL_UI = 'experimental_ui';

View file

@ -15,6 +15,7 @@ export default function useGetThumbnail(
const isImage = claim && claim.value && claim.value.stream_type === 'image';
// $FlowFixMe
const isFree = claim && claim.value && (!claim.value.fee || Number(claim.value.fee.amount) <= 0);
const isCollection = claim && claim.value_type === 'collection';
const thumbnailInClaim = claim && claim.value && claim.value.thumbnail && claim.value.thumbnail.url;
// @if TARGET='web'
@ -22,6 +23,8 @@ export default function useGetThumbnail(
thumbnailToUse = thumbnailInClaim;
} else if (claim && isImage && isFree) {
thumbnailToUse = generateStreamUrl(claim.name, claim.claim_id);
} else if (isCollection) {
thumbnailToUse = 'http://spee.ch/default-thumb-odysee:e.jpg';
}
// @endif

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ClaimCollectionAdd from './view';
const select = (state) => ({});
const perform = {
doHideModal,
};
export default connect(select, perform)(ClaimCollectionAdd);

View file

@ -0,0 +1,19 @@
// @flow
import React from 'react';
import ClaimCollectionAdd from 'component/claimCollectionAdd';
import { Modal } from 'modal/modal';
type Props = {
doHideModal: () => void,
uri: string,
};
const ModalClaimCollectionAdd = (props: Props) => {
const { doHideModal, uri } = props;
return (
<Modal isOpen type="card" onAborted={doHideModal}>
<ClaimCollectionAdd uri={uri} closeModal={doHideModal} />
</Modal>
);
};
export default ModalClaimCollectionAdd;

View file

@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import {
makeSelectClaimIsMine,
makeSelectIsAbandoningClaimForUri,
doCollectionDelete,
makeSelectClaimForClaimId,
makeSelectNameForCollectionId,
} from 'lbry-redux';
import { doHideModal } from 'redux/actions/app';
import ModalRemoveCollection from './view';
const select = (state, props) => {
const claim = makeSelectClaimForClaimId(props.collectionId)(state);
const uri = (claim && (claim.canonical_url || claim.permanent_url)) || null;
return {
claim,
uri,
claimIsMine: makeSelectClaimIsMine(uri)(state),
isAbandoning: makeSelectIsAbandoningClaimForUri(uri)(state),
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
};
};
const perform = (dispatch) => ({
closeModal: () => dispatch(doHideModal()),
collectionDelete: (id) => dispatch(doCollectionDelete(id)),
});
export default connect(select, perform)(ModalRemoveCollection);

View file

@ -0,0 +1,65 @@
// @flow
import React, { useState } from 'react';
import { Modal } from 'modal/modal';
import Button from 'component/button';
import Card from 'component/common/card';
import I18nMessage from 'component/i18nMessage';
import { useHistory } from 'react-router-dom';
import { FormField } from 'component/common/form';
type Props = {
closeModal: () => void,
collectionDelete: (string, ?string) => void,
claim: Claim,
collectionId: string,
collectionName: string,
uri: ?string,
redirect: ?string,
};
function ModalRemoveCollection(props: Props) {
const { closeModal, claim, collectionDelete, collectionId, collectionName, uri, redirect } = props;
const title = claim && claim.value && claim.value.title;
const { replace } = useHistory();
const [confirmName, setConfirmName] = useState('');
return (
<Modal isOpen contentLabel={__('Confirm Collection Unpublish')} type="card" onAborted={closeModal}>
<Card
title={__('Delete List')}
body={
uri ? (
<React.Fragment>
<p>{__('This will permanently delete the list.')}</p>
<p>{__('Type "%name%" to confirm.', { name: collectionName })}</p>
<FormField value={confirmName} type={'text'} onChange={(e) => setConfirmName(e.target.value)} />
</React.Fragment>
) : (
<I18nMessage tokens={{ title: <cite>{uri && title ? `"${title}"` : `"${collectionName}"`}</cite> }}>
Are you sure you'd like to remove collection %title%?
</I18nMessage>
)
}
actions={
<>
<div className="section__actions">
<Button
button="primary"
label={__('Delete')}
disabled={uri && collectionName !== confirmName}
onClick={() => {
if (redirect) replace(redirect);
collectionDelete(collectionId, uri ? 'resolved' : undefined);
closeModal();
}}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />
</div>
</>
}
/>
</Modal>
);
}
export default ModalRemoveCollection;

View file

@ -40,6 +40,8 @@ import ModalMobileSearch from 'modal/modalMobileSearch';
import ModalViewImage from 'modal/modalViewImage';
import ModalMassTipsUnlock from 'modal/modalMassTipUnlock';
import ModalRemoveBtcSwapAddress from 'modal/modalRemoveBtcSwapAddress';
import ModalClaimCollectionAdd from 'modal/modalClaimCollectionAdd';
import ModalDeleteCollection from 'modal/modalRemoveCollection';
type Props = {
modal: { id: string, modalProps: {} },
@ -143,6 +145,10 @@ function ModalRouter(props: Props) {
return <ModalMassTipsUnlock {...modalProps} />;
case MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS:
return <ModalRemoveBtcSwapAddress {...modalProps} />;
case MODALS.COLLECTION_ADD:
return <ModalClaimCollectionAdd {...modalProps} />;
case MODALS.COLLECTION_DELETE:
return <ModalDeleteCollection {...modalProps} />;
default:
return null;
}

View file

@ -24,6 +24,8 @@ import ClaimMenuList from 'component/claimMenuList';
import Yrbl from 'component/yrbl';
export const PAGE_VIEW_QUERY = `view`;
const CONTENT_PAGE = 'content';
const LISTS_PAGE = 'lists';
const ABOUT_PAGE = `about`;
export const DISCUSSION_PAGE = `discussion`;
const EDIT_PAGE = 'edit';
@ -57,7 +59,7 @@ function ChannelPage(props: Props) {
claim,
title,
cover,
page,
// page, ?page= may come back some day?
channelIsMine,
isSubscribed,
blackListedOutpoints,
@ -107,15 +109,34 @@ function ChannelPage(props: Props) {
// If a user changes tabs, update the url so it stays on the same page if they refresh.
// We don't want to use links here because we can't animate the tab change and using links
// would alter the Tab label's role attribute, which should stay role="tab" to work with keyboards/screen readers.
const tabIndex = currentView === ABOUT_PAGE || editing ? 1 : currentView === DISCUSSION_PAGE ? 2 : 0;
let tabIndex;
switch (currentView) {
case CONTENT_PAGE:
tabIndex = 0;
break;
case LISTS_PAGE:
tabIndex = 1;
break;
case ABOUT_PAGE:
tabIndex = 2;
break;
case DISCUSSION_PAGE:
tabIndex = 3;
break;
default:
tabIndex = 0;
break;
}
function onTabChange(newTabIndex) {
let url = formatLbryUrlForWeb(uri);
let search = '?';
if (newTabIndex === 0) {
search += `page=${page}`;
search += `${PAGE_VIEW_QUERY}=${CONTENT_PAGE}`;
} else if (newTabIndex === 1) {
search += `${PAGE_VIEW_QUERY}=${LISTS_PAGE}`;
} else if (newTabIndex === 2) {
search += `${PAGE_VIEW_QUERY}=${ABOUT_PAGE}`;
} else {
search += `${PAGE_VIEW_QUERY}=${DISCUSSION_PAGE}`;
@ -164,6 +185,7 @@ function ChannelPage(props: Props) {
{!channelIsBlackListed && <ShareButton uri={uri} />}
{!(isBlocked || isMuted) && <ClaimSupportButton uri={uri} />}
{!(isBlocked || isMuted) && (!channelIsBlackListed || isSubscribed) && <SubscribeButton uri={permanentUrl} />}
{/* TODO: add channel collections <ClaimCollectionAddButton uri={uri} fileAction /> */}
<ClaimMenuList uri={claim.permanent_url} inline />
</div>
{cover && <img className={classnames('channel-cover__custom')} src={cover} />}
@ -228,13 +250,27 @@ function ChannelPage(props: Props) {
) : (
<Tabs onChange={onTabChange} index={tabIndex}>
<TabList className="tabs__list--channel-page">
<Tab disabled={editing}>{__('Content')}</Tab>
<Tab disabled={editing}>{__('Publishes')}</Tab>
<Tab disabled={editing}>{__('Playlists')}</Tab>
<Tab>{editing ? __('Editing Your Channel') : __('About --[tab title in Channel Page]--')}</Tab>
<Tab disabled={editing}>{__('Community')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<ChannelContent uri={uri} channelIsBlackListed={channelIsBlackListed} viewHiddenChannels />
<ChannelContent
claimType={'stream'}
uri={uri}
channelIsBlackListed={channelIsBlackListed}
viewHiddenChannels
/>
</TabPanel>
<TabPanel>
<ChannelContent
claimType={'collection'}
uri={uri}
channelIsBlackListed={channelIsBlackListed}
viewHiddenChannels
/>
</TabPanel>
<TabPanel>
<ChannelAbout uri={uri} />

View file

@ -0,0 +1,57 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import CollectionPage from './view';
import {
doFetchItemsInCollection,
makeSelectCollectionForId,
makeSelectUrlsForCollectionId,
makeSelectIsResolvingCollectionForId,
makeSelectTitleForUri,
makeSelectThumbnailForUri,
makeSelectClaimIsMine,
makeSelectClaimIsPending,
makeSelectClaimForClaimId,
makeSelectCollectionIsMine,
doLocalCollectionDelete,
doCollectionEdit,
makeSelectChannelForClaimUri,
makeSelectCountForCollectionId,
makeSelectEditedCollectionForId,
} from 'lbry-redux';
import { selectUser } from 'redux/selectors/user';
const select = (state, props) => {
const { match } = props;
const { params } = match;
const { collectionId } = params;
const claim = collectionId && makeSelectClaimForClaimId(collectionId)(state);
const uri = (claim && (claim.canonical_url || claim.permanent_url)) || null;
return {
collectionId,
claim,
collection: makeSelectCollectionForId(collectionId)(state),
collectionUrls: makeSelectUrlsForCollectionId(collectionId)(state),
collectionCount: makeSelectCountForCollectionId(collectionId)(state),
isResolvingCollection: makeSelectIsResolvingCollectionForId(collectionId)(state),
title: makeSelectTitleForUri(uri)(state),
thumbnail: makeSelectThumbnailForUri(uri)(state),
isMyClaim: makeSelectClaimIsMine(uri)(state), // or collection is mine?
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
claimIsPending: makeSelectClaimIsPending(uri)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(collectionId)(state)),
uri,
user: selectUser(state),
channel: uri && makeSelectChannelForClaimUri(uri)(state),
};
};
const perform = (dispatch) => ({
fetchCollectionItems: (claimId, cb) => dispatch(doFetchItemsInCollection({ collectionId: claimId }, cb)), // if this collection is not resolved, resolve it
deleteCollection: (id) => dispatch(doLocalCollectionDelete(id)),
editCollection: (id, params) => dispatch(doCollectionEdit(id, params)),
});
export default withRouter(connect(select, perform)(CollectionPage));

175
ui/page/collection/view.jsx Normal file
View file

@ -0,0 +1,175 @@
// @flow
import React from 'react';
import ClaimList from 'component/claimList';
import Page from 'component/page';
import * as PAGES from 'constants/pages';
import { useHistory } from 'react-router-dom';
import CollectionEdit from 'component/collectionEdit';
import Card from 'component/common/card';
import CollectionActions from 'component/collectionActions';
import classnames from 'classnames';
import ClaimAuthor from 'component/claimAuthor';
import FileDescription from 'component/fileDescription';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import Spinner from 'component/spinner';
export const PAGE_VIEW_QUERY = 'view';
export const EDIT_PAGE = 'edit';
type Props = {
collectionId: string,
uri: string,
claim: Claim,
title: string,
thumbnail: string,
collection: Collection,
collectionUrls: Array<string>,
collectionCount: number,
isResolvingCollection: boolean,
isMyClaim: boolean,
isMyCollection: boolean,
claimIsPending: boolean,
collectionHasEdits: boolean,
deleteCollection: (string) => void,
editCollection: (string, CollectionEditParams) => void,
fetchCollectionItems: (string, () => void) => void,
resolveUris: (string) => void,
user: ?User,
};
export default function CollectionPage(props: Props) {
const {
collectionId,
uri,
claim,
collection,
collectionUrls,
collectionCount,
collectionHasEdits,
claimIsPending,
isResolvingCollection,
fetchCollectionItems,
} = props;
const {
replace,
location: { search },
} = useHistory();
const [didTryResolve, setDidTryResolve] = React.useState(false);
const [showInfo, setShowInfo] = React.useState(false);
const { name, totalItems } = collection || {};
const isBuiltin = COLLECTIONS_CONSTS.BUILTIN_LISTS.includes(collectionId);
const urlParams = new URLSearchParams(search);
const editing = urlParams.get(PAGE_VIEW_QUERY) === EDIT_PAGE;
const urlsReady =
collectionUrls && (totalItems === undefined || (totalItems && totalItems === collectionUrls.length));
const shouldFetch = !claim && !collection;
React.useEffect(() => {
if (collectionId && !urlsReady && !didTryResolve && shouldFetch) {
fetchCollectionItems(collectionId, () => setDidTryResolve(true));
}
}, [collectionId, urlsReady, didTryResolve, shouldFetch, setDidTryResolve, fetchCollectionItems]);
const pending = (
<div className="help card__title--help">
<Spinner type={'small'} />
{__('Your publish is being confirmed and will be live soon')}
</div>
);
const unpublished = <span className="help">{__('Unpublished Edit')}</span>;
let titleActions;
if (collectionHasEdits) {
titleActions = unpublished;
} else if (claimIsPending) {
titleActions = pending;
}
const subTitle = (
<div>
{uri ? <span>{collectionCount} items</span> : <span>{collectionCount} items</span>}
{uri && <ClaimAuthor uri={uri} />}
</div>
);
const info = (
<Card
title={
<span>
<Icon icon={ICONS.STACK} className="icon--margin-right" />
{claim ? claim.value.title || claim.name : collection && collection.name}
{collectionHasEdits && <span className={'collection-title__hasEdits'}>(*)</span>}
</span>
}
titleActions={titleActions}
subtitle={subTitle}
body={
!isBuiltin && (
<CollectionActions uri={uri} collectionId={collectionId} setShowInfo={setShowInfo} showInfo={showInfo} />
)
}
actions={
showInfo &&
uri && (
<div className="section">
<FileDescription uri={uri} expandOverride />
</div>
)
}
/>
);
if (!collection && (isResolvingCollection || !didTryResolve)) {
return (
<Page>
<h2 className="main--empty empty">{__('Loading...')}</h2>
</Page>
);
}
if (!collection && !isResolvingCollection && didTryResolve) {
return (
<Page>
<h2 className="main--empty empty">{__('Nothing here')}</h2>
</Page>
);
}
if (editing) {
return (
<Page
noFooter
noSideNavigation={editing}
backout={{
title: __('%action% %collection%', { collection: name, action: uri ? __('Editing') : __('Publishing') }),
simpleTitle: uri ? __('Editing') : __('Publishing'),
}}
>
<CollectionEdit
uri={uri}
collectionId={collectionId}
onDone={(id) => {
replace(`/$/${PAGES.LIST}/${id}`);
}}
/>
</Page>
);
}
if (urlsReady) {
return (
<Page>
<div className={classnames('section card-stack')}>
{info}
<ClaimList uris={collectionUrls} collectionId={collectionId} />
</div>
</Page>
);
}
}

View file

@ -10,6 +10,8 @@ import {
makeSelectTagInClaimOrChannelForUri,
makeSelectClaimIsMine,
makeSelectClaimIsStreamPlaceholder,
makeSelectCollectionForId,
COLLECTIONS_CONSTS,
} from 'lbry-redux';
import { makeSelectCostInfoForUri, doFetchCostInfoForUri } from 'lbryinc';
import { selectShowMatureContent, makeSelectClientSetting } from 'redux/selectors/settings';
@ -23,6 +25,7 @@ const select = (state, props) => {
const { search } = props.location;
const urlParams = new URLSearchParams(search);
const linkedCommentId = urlParams.get('lc');
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
return {
linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state),
@ -36,6 +39,8 @@ const select = (state, props) => {
commentsDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
collection: makeSelectCollectionForId(collectionId)(state),
collectionId,
};
};

View file

@ -8,6 +8,7 @@ import FileRenderInitiator from 'component/fileRenderInitiator';
import FileRenderInline from 'component/fileRenderInline';
import FileRenderDownload from 'component/fileRenderDownload';
import RecommendedContent from 'component/recommendedContent';
import CollectionContent from 'component/collectionContentSidebar';
import CommentsList from 'component/commentsList';
import PostViewer from 'component/postViewer';
import Empty from 'component/common/empty';
@ -26,6 +27,8 @@ type Props = {
isMature: boolean,
linkedComment: any,
setPrimaryUri: (?string) => void,
collection?: Collection,
collectionId: string,
videoTheaterMode: boolean,
commentsDisabled: boolean,
};
@ -45,6 +48,8 @@ function FilePage(props: Props) {
setPrimaryUri,
videoTheaterMode,
commentsDisabled,
collection,
collectionId,
} = props;
const cost = costInfo ? costInfo.cost : null;
const hasFileInfo = fileInfo !== undefined;
@ -115,8 +120,12 @@ function FilePage(props: Props) {
if (obscureNsfw && isMature) {
return (
<Page>
<FileTitleSection uri={uri} isNsfwBlocked />
<Page className="file-page" filePage isMarkdown={isMarkdown}>
<div className={classnames('section card-stack', `file-page__${renderMode}`)}>
<FileTitleSection uri={uri} isNsfwBlocked />
</div>
{collection && !isMarkdown && !videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
{!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
</Page>
);
}
@ -133,12 +142,13 @@ function FilePage(props: Props) {
{commentsDisabled && <Empty text={__('The creator of this content has disabled comments.')} />}
{!commentsDisabled && <CommentsList uri={uri} linkedComment={linkedComment} />}
</div>
{videoTheaterMode && <RecommendedContent uri={uri} />}
{!collection && !isMarkdown && videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
</div>
)}
</div>
{!isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && !videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
{!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
{isMarkdown && (
<div className="file-page__post-comments">
<CommentsList uri={uri} linkedComment={linkedComment} />

View file

@ -19,7 +19,7 @@ type Props = {
fetchingFileList: boolean,
downloadedUrls: Array<string>,
downloadedUrlsCount: ?number,
history: { replace: string => void },
history: { replace: (string) => void },
query: string,
doPurchaseList: () => void,
myDownloads: Array<string>,

31
ui/page/lists/index.js Normal file
View file

@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import {
selectDownloadUrlsCount,
selectIsFetchingFileList,
selectMyPurchases,
selectIsFetchingMyPurchases,
doPurchaseList,
selectBuiltinCollections,
selectMyPublishedMixedCollections,
selectMyPublishedPlaylistCollections,
selectMyUnpublishedCollections, // should probably distinguish types
// selectSavedCollections, // TODO: implement saving and copying collections
} from 'lbry-redux';
import ListsPage from './view';
const select = (state) => ({
allDownloadedUrlsCount: selectDownloadUrlsCount(state),
fetchingFileList: selectIsFetchingFileList(state),
myPurchases: selectMyPurchases(state),
fetchingMyPurchases: selectIsFetchingMyPurchases(state),
builtinCollections: selectBuiltinCollections(state),
publishedCollections: selectMyPublishedMixedCollections(state),
publishedPlaylists: selectMyPublishedPlaylistCollections(state),
unpublishedCollections: selectMyUnpublishedCollections(state),
// savedCollections: selectSavedCollections(state),
});
export default connect(select, {
doPurchaseList,
})(ListsPage);

22
ui/page/lists/view.jsx Normal file
View file

@ -0,0 +1,22 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Page from 'component/page';
import CollectionsListMine from 'component/collectionsListMine';
import Icon from 'component/common/icon';
function ListsPage() {
return (
<Page>
<label className="claim-list__header-label">
<span>
<Icon icon={ICONS.STACK} size={10} />
{__('Lists')}
</span>
</label>
<CollectionsListMine />
</Page>
);
}
export default ListsPage;

View file

@ -1,6 +1,7 @@
import * as PAGES from 'constants/pages';
import { DOMAIN } from 'config';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { PAGE_SIZE } from 'constants/claim';
import {
doResolveUri,
@ -14,6 +15,11 @@ import {
makeSelectClaimIsStreamPlaceholder,
doClearPublish,
doPrepareEdit,
doFetchItemsInCollection,
makeSelectCollectionForId,
makeSelectUrlsForCollectionId,
makeSelectIsResolvingCollectionForId,
COLLECTIONS_CONSTS,
} from 'lbry-redux';
import { push } from 'connected-react-router';
import { makeSelectChannelInSubscriptions } from 'redux/selectors/subscriptions';
@ -23,6 +29,8 @@ import ShowPage from './view';
const select = (state, props) => {
const { pathname, hash, search } = props.location;
const urlPath = pathname + hash;
const urlParams = new URLSearchParams(search);
// Remove the leading "/" added by the browser
let path = urlPath.slice(1).replace(/:/g, '#');
@ -54,10 +62,15 @@ const select = (state, props) => {
props.history.replace(`/${path.slice(0, match.index)}`);
}
}
const claim = makeSelectClaimForUri(uri)(state);
const collectionId =
urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID) ||
(claim && claim.value_type === 'collection' && claim.claim_id) ||
null;
return {
uri,
claim: makeSelectClaimForUri(uri)(state),
claim,
isResolvingUri: makeSelectIsUriResolving(uri)(state),
blackListedOutpoints: selectBlackListedOutpoints(state),
totalPages: makeSelectTotalPagesForChannel(uri, PAGE_SIZE)(state),
@ -66,6 +79,10 @@ const select = (state, props) => {
claimIsMine: makeSelectClaimIsMine(uri)(state),
claimIsPending: makeSelectClaimIsPending(uri)(state),
isLivestream: makeSelectClaimIsStreamPlaceholder(uri)(state),
collection: makeSelectCollectionForId(collectionId)(state),
collectionId: collectionId,
collectionUrls: makeSelectUrlsForCollectionId(collectionId)(state),
isResolvingCollection: makeSelectIsResolvingCollectionForId(collectionId)(state),
};
};
@ -76,6 +93,7 @@ const perform = (dispatch) => ({
dispatch(doPrepareEdit({ name }));
dispatch(push(`/$/${PAGES.UPLOAD}`));
},
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })),
});
export default connect(select, perform)(ShowPage);
export default withRouter(connect(select, perform)(ShowPage));

View file

@ -13,7 +13,7 @@ import Card from 'component/common/card';
import AbandonedChannelPreview from 'component/abandonedChannelPreview';
import Yrbl from 'component/yrbl';
import { formatLbryUrlForWeb } from 'util/url';
import { parseURI } from 'lbry-redux';
import { parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
type Props = {
isResolvingUri: boolean,
@ -31,6 +31,11 @@ type Props = {
claimIsPending: boolean,
isLivestream: boolean,
beginPublish: (string) => void,
collectionId: string,
collection: Collection,
collectionUrls: Array<string>,
isResolvingCollection: boolean,
fetchCollectionItems: (string) => void,
};
function ShowPage(props: Props) {
@ -46,8 +51,15 @@ function ShowPage(props: Props) {
claimIsPending,
isLivestream,
beginPublish,
fetchCollectionItems,
collectionId,
collection,
collectionUrls,
isResolvingCollection,
} = props;
const { search } = location;
const signingChannel = claim && claim.signing_channel;
const canonicalUrl = claim && claim.canonical_url;
const claimExists = claim !== null && claim !== undefined;
@ -55,6 +67,15 @@ function ShowPage(props: Props) {
const isMine = claim && claim.is_my_output;
const { contentName, isChannel } = parseURI(uri);
const { push } = useHistory();
const isCollection = claim && claim.value_type === 'collection';
const resolvedCollection = collection && collection.id; // not null
// changed this from 'isCollection' to resolve strangers' collections.
React.useEffect(() => {
if (collectionId && !resolvedCollection) {
fetchCollectionItems(collectionId);
}
}, [isCollection, resolvedCollection, collectionId, fetchCollectionItems]);
useEffect(() => {
// @if TARGET='web'
@ -84,12 +105,24 @@ function ShowPage(props: Props) {
const newUrl = formatLbryUrlForWeb(claim.canonical_url);
return <Redirect to={newUrl} />;
}
let urlForCollectionZero;
if (claim && claim.value_type === 'collection' && collectionUrls && collectionUrls.length) {
urlForCollectionZero = collectionUrls && collectionUrls[0];
const claimId = claim.claim_id;
const urlParams = new URLSearchParams(search);
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, claimId);
const newUrl = formatLbryUrlForWeb(`${urlForCollectionZero}?${urlParams.toString()}`);
return <Redirect to={newUrl} />;
}
let innerContent = '';
if (!claim || (claim && !claim.name)) {
innerContent = (
<Page>
{(claim === undefined || isResolvingUri) && (
{(claim === undefined ||
isResolvingUri ||
isResolvingCollection || // added for collection
(claim && claim.value_type === 'collection' && !urlForCollectionZero)) && ( // added for collection - make sure we accept urls = []
<div className="main--empty">
<Spinner delayed />
</div>

View file

@ -1,6 +1,6 @@
import { combineReducers } from 'redux';
import { connectRouter } from 'connected-react-router';
import { claimsReducer, fileInfoReducer, walletReducer, publishReducer } from 'lbry-redux';
import { claimsReducer, fileInfoReducer, walletReducer, publishReducer, collectionsReducer } from 'lbry-redux';
import { costInfoReducer, blacklistReducer, filteredReducer, homepageReducer, statsReducer, webReducer } from 'lbryinc';
import appReducer from 'redux/reducers/app';
import tagsReducer from 'redux/reducers/tags';
@ -48,4 +48,5 @@ export default (history) =>
wallet: walletReducer,
sync: syncReducer,
web: webReducer,
collections: collectionsReducer,
});

View file

@ -15,6 +15,7 @@ import {
makeSelectClaimIsMine,
doPopulateSharedUserState,
doFetchChannelListMine,
doFetchCollectionListMine,
doClearPublish,
doPreferenceGet,
doClearSupport,
@ -552,6 +553,7 @@ export function doSignIn() {
// @if TARGET='web'
dispatch(doBalanceSubscribe());
dispatch(doFetchChannelListMine());
dispatch(doFetchCollectionListMine());
// @endif
};
}

View file

@ -1,26 +1,29 @@
import { createSelector } from 'reselect';
export const selectState = state => state.user || {};
export const selectState = (state) => state.user || {};
export const selectAuthenticationIsPending = createSelector(selectState, state => state.authenticationIsPending);
export const selectAuthenticationIsPending = createSelector(selectState, (state) => state.authenticationIsPending);
export const selectUserIsPending = createSelector(selectState, state => state.userIsPending);
export const selectUserIsPending = createSelector(selectState, (state) => state.userIsPending);
export const selectUser = createSelector(selectState, state => state.user);
export const selectUser = createSelector(selectState, (state) => state.user);
export const selectEmailAlreadyExists = createSelector(selectState, state => state.emailAlreadyExists);
export const selectEmailAlreadyExists = createSelector(selectState, (state) => state.emailAlreadyExists);
export const selectEmailDoesNotExist = createSelector(selectState, state => state.emailDoesNotExist);
export const selectEmailDoesNotExist = createSelector(selectState, (state) => state.emailDoesNotExist);
export const selectResendingVerificationEmail = createSelector(selectState, state => state.resendingVerificationEmail);
export const selectResendingVerificationEmail = createSelector(
selectState,
(state) => state.resendingVerificationEmail
);
export const selectUserEmail = createSelector(selectUser, user =>
export const selectUserEmail = createSelector(selectUser, (user) =>
user ? user.primary_email || user.latest_claimed_email : null
);
export const selectUserPhone = createSelector(selectUser, user => (user ? user.phone_number : null));
export const selectUserPhone = createSelector(selectUser, (user) => (user ? user.phone_number : null));
export const selectUserCountryCode = createSelector(selectUser, user => (user ? user.country_code : null));
export const selectUserCountryCode = createSelector(selectUser, (user) => (user ? user.country_code : null));
export const selectEmailToVerify = createSelector(
selectState,
@ -34,92 +37,95 @@ export const selectPhoneToVerify = createSelector(
(state, userPhone) => state.phoneToVerify || userPhone
);
export const selectYoutubeChannels = createSelector(selectUser, user => (user ? user.youtube_channels : null));
export const selectYoutubeChannels = createSelector(selectUser, (user) => (user ? user.youtube_channels : null));
export const selectUserIsRewardApproved = createSelector(selectUser, user => user && user.is_reward_approved);
export const selectUserIsRewardApproved = createSelector(selectUser, (user) => user && user.is_reward_approved);
export const selectEmailNewIsPending = createSelector(selectState, state => state.emailNewIsPending);
export const selectEmailNewIsPending = createSelector(selectState, (state) => state.emailNewIsPending);
export const selectEmailNewErrorMessage = createSelector(selectState, state => {
export const selectEmailNewErrorMessage = createSelector(selectState, (state) => {
const error = state.emailNewErrorMessage;
return typeof error === 'object' && error !== null ? error.message : error;
});
export const selectPasswordExists = createSelector(selectState, state => state.passwordExistsForUser);
export const selectPasswordExists = createSelector(selectState, (state) => state.passwordExistsForUser);
export const selectPasswordResetIsPending = createSelector(selectState, state => state.passwordResetPending);
export const selectPasswordResetIsPending = createSelector(selectState, (state) => state.passwordResetPending);
export const selectPasswordResetSuccess = createSelector(selectState, state => state.passwordResetSuccess);
export const selectPasswordResetSuccess = createSelector(selectState, (state) => state.passwordResetSuccess);
export const selectPasswordResetError = createSelector(selectState, state => {
export const selectPasswordResetError = createSelector(selectState, (state) => {
const error = state.passwordResetError;
return typeof error === 'object' && error !== null ? error.message : error;
});
export const selectPasswordSetIsPending = createSelector(selectState, state => state.passwordSetPending);
export const selectPasswordSetIsPending = createSelector(selectState, (state) => state.passwordSetPending);
export const selectPasswordSetSuccess = createSelector(selectState, state => state.passwordSetSuccess);
export const selectPasswordSetSuccess = createSelector(selectState, (state) => state.passwordSetSuccess);
export const selectPasswordSetError = createSelector(selectState, state => {
export const selectPasswordSetError = createSelector(selectState, (state) => {
const error = state.passwordSetError;
return typeof error === 'object' && error !== null ? error.message : error;
});
export const selectPhoneNewErrorMessage = createSelector(selectState, state => state.phoneNewErrorMessage);
export const selectPhoneNewErrorMessage = createSelector(selectState, (state) => state.phoneNewErrorMessage);
export const selectEmailVerifyIsPending = createSelector(selectState, state => state.emailVerifyIsPending);
export const selectEmailVerifyIsPending = createSelector(selectState, (state) => state.emailVerifyIsPending);
export const selectEmailVerifyErrorMessage = createSelector(selectState, state => state.emailVerifyErrorMessage);
export const selectEmailVerifyErrorMessage = createSelector(selectState, (state) => state.emailVerifyErrorMessage);
export const selectPhoneNewIsPending = createSelector(selectState, state => state.phoneNewIsPending);
export const selectPhoneNewIsPending = createSelector(selectState, (state) => state.phoneNewIsPending);
export const selectPhoneVerifyIsPending = createSelector(selectState, state => state.phoneVerifyIsPending);
export const selectPhoneVerifyIsPending = createSelector(selectState, (state) => state.phoneVerifyIsPending);
export const selectPhoneVerifyErrorMessage = createSelector(selectState, state => state.phoneVerifyErrorMessage);
export const selectPhoneVerifyErrorMessage = createSelector(selectState, (state) => state.phoneVerifyErrorMessage);
export const selectIdentityVerifyIsPending = createSelector(selectState, state => state.identityVerifyIsPending);
export const selectIdentityVerifyIsPending = createSelector(selectState, (state) => state.identityVerifyIsPending);
export const selectIdentityVerifyErrorMessage = createSelector(selectState, state => state.identityVerifyErrorMessage);
export const selectIdentityVerifyErrorMessage = createSelector(
selectState,
(state) => state.identityVerifyErrorMessage
);
export const selectUserVerifiedEmail = createSelector(selectUser, user => user && user.has_verified_email);
export const selectUserVerifiedEmail = createSelector(selectUser, (user) => user && user.has_verified_email);
export const selectUserIsVerificationCandidate = createSelector(
selectUser,
user => user && (!user.has_verified_email || !user.is_identity_verified)
(user) => user && (!user.has_verified_email || !user.is_identity_verified)
);
export const selectAccessToken = createSelector(selectState, state => state.accessToken);
export const selectAccessToken = createSelector(selectState, (state) => state.accessToken);
export const selectUserInviteStatusIsPending = createSelector(selectState, state => state.inviteStatusIsPending);
export const selectUserInviteStatusIsPending = createSelector(selectState, (state) => state.inviteStatusIsPending);
export const selectUserInvitesRemaining = createSelector(selectState, state => state.invitesRemaining);
export const selectUserInvitesRemaining = createSelector(selectState, (state) => state.invitesRemaining);
export const selectUserInvitees = createSelector(selectState, state => state.invitees);
export const selectUserInvitees = createSelector(selectState, (state) => state.invitees);
export const selectUserInviteStatusFailed = createSelector(
selectUserInvitesRemaining,
() => selectUserInvitesRemaining === null
);
export const selectUserInviteNewIsPending = createSelector(selectState, state => state.inviteNewIsPending);
export const selectUserInviteNewIsPending = createSelector(selectState, (state) => state.inviteNewIsPending);
export const selectUserInviteNewErrorMessage = createSelector(selectState, state => state.inviteNewErrorMessage);
export const selectUserInviteNewErrorMessage = createSelector(selectState, (state) => state.inviteNewErrorMessage);
export const selectUserInviteReferralLink = createSelector(selectState, state => state.referralLink);
export const selectUserInviteReferralLink = createSelector(selectState, (state) => state.referralLink);
export const selectUserInviteReferralCode = createSelector(selectState, state =>
export const selectUserInviteReferralCode = createSelector(selectState, (state) =>
state.referralCode ? state.referralCode[0] : ''
);
export const selectYouTubeImportPending = createSelector(selectState, state => state.youtubeChannelImportPending);
export const selectYouTubeImportPending = createSelector(selectState, (state) => state.youtubeChannelImportPending);
export const selectYouTubeImportError = createSelector(selectState, state => state.youtubeChannelImportErrorMessage);
export const selectYouTubeImportError = createSelector(selectState, (state) => state.youtubeChannelImportErrorMessage);
export const selectSetReferrerPending = createSelector(selectState, state => state.referrerSetIsPending);
export const selectSetReferrerPending = createSelector(selectState, (state) => state.referrerSetIsPending);
export const selectSetReferrerError = createSelector(selectState, state => state.referrerSetError);
export const selectSetReferrerError = createSelector(selectState, (state) => state.referrerSetError);
export const selectYouTubeImportVideosComplete = createSelector(selectState, state => {
export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => {
const total = state.youtubeChannelImportTotal;
const complete = state.youtubeChannelImportComplete || 0;
@ -127,3 +133,5 @@ export const selectYouTubeImportVideosComplete = createSelector(selectState, sta
return [complete, total];
}
});
export const makeSelectUserPropForProp = (prop) => createSelector(selectUser, (user) => (user ? user[prop] : null));

View file

@ -14,6 +14,7 @@
@import 'component/card';
@import 'component/channel';
@import 'component/claim-list';
@import 'component/collection';
@import 'component/comments';
@import 'component/content';
@import 'component/dat-gui';

Some files were not shown because too many files have changed in this diff Show more