Playlists v2: Refactors, touch ups + Queue Mode (#1604)

* Playlists v2

* Style pass

* Change playlist items arrange icon

* Playlist card body open by default

* Refactor collectionEdit components

* Paginate & Refactor bid field

* Collection page changes

* Add Thumbnail optional

* Replace extra info for description on collection page

* Playlist card right below video on medium screen

* Allow editing private collections

* Add edit option to menus

* Allow deleting a public playlist but keeping a private version

* Add queue to Save menu, remove edit option from Builtin pages, show queue on playlists page

* Fix scroll to recent persisting on medium screen

* Fix adding to queue from menu

* Fixes for delete

* PublishList: delay mounting Items tab to prevent lock-up (#1783)

For a large list, the playlist publish form is unusable (super-slow typing) due to the entire list being mounted despite the tab is not active.
The full solution is still to paginate it, but for now, don't mount the tab until it is selected. Add a spinner to indicate something is loading. It's not prefect, but it's throwaway code anyway. At least we can fill in the fields properly now.

* Batch-resolve private collections (#1782)

* makeSelectClaimForClaimId --> selectClaimForClaimId

Move away from the problematic `makeSelect*`, especially in large loops.

* Batch-resolve private collections
1758

This alleviates the lock-up that is caused by large number of invidual resolves. There will still be some minor stutter due to the large DOM that React needs to handle -- that is logged in 1758 and will be handled separately.

At least the stutter is short (1-2s) and the app is still usable.
Private list items are being resolve individually, super slow if the list is large (>100). Published lists doesn't have this issue.
doFetchItemsInCollections contains most of the useful logic, but it isn't called for private/built-in lists because it's not an actual claim.
Tweaked doFetchItemsInCollections to handle private (UUID-based) collections.

* Use persisted state for floating player playlist card body
- I find it annoying being open everytime

* Fix removing edits from published playlist

* Fix scroll on mobile

* Allow going editing items from toast

* Fix ClaimShareButton

* Prevent edit/publish of builtin

* Fix async inside forEach

* Fix sync on queue edit

* Fix autoplayCountdown replay

* Fix deleting an item scrolling the playlist

* CreatedAt fixes

* Remove repost for now

* Anon publish fixes

* Fix mature case on floating

Co-authored-by: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com>
This commit is contained in:
Rafael Saes 2022-07-13 10:59:59 -03:00 committed by GitHub
parent 5863ea8df4
commit 83dbe8ec7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
240 changed files with 8236 additions and 3950 deletions

View file

@ -5,32 +5,33 @@ import { selectClaimForUri } from 'redux/selectors/claims';
// eslint-disable-next-line import/prefer-default-export
export function doFetchCostInfoForUri(uri: string) {
return (dispatch: Dispatch, getState: GetState) => {
return async (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const claim = selectClaimForUri(state, uri);
if (!claim) return;
function resolve(costInfo) {
dispatch({
type: ACTIONS.FETCH_COST_INFO_COMPLETED,
data: {
uri,
costInfo,
},
});
}
const fee = claim.value ? claim.value.fee : undefined;
let costInfo;
if (fee === undefined) {
resolve({ cost: 0, includesData: true });
costInfo = { cost: 0, includesData: true };
} else if (fee.currency === 'LBC') {
resolve({ cost: fee.amount, includesData: true });
costInfo = { cost: fee.amount, includesData: true };
} else {
Lbryio.getExchangeRates().then(({ LBC_USD }) => {
resolve({ cost: fee.amount / LBC_USD, includesData: true });
await Lbryio.getExchangeRates().then(({ LBC_USD }) => {
costInfo = { cost: fee.amount / LBC_USD, includesData: true };
});
}
dispatch({
type: ACTIONS.FETCH_COST_INFO_COMPLETED,
data: {
uri,
costInfo,
},
});
return costInfo;
};
}

View file

@ -2,9 +2,16 @@ declare type Collection = {
id: string,
items: Array<?string>,
name: string,
description?: string,
thumbnail?: {
url?: string,
},
type: string,
createdAt?: ?number,
updatedAt: number,
totalItems?: number,
itemCount?: number,
editsCleared?: boolean,
sourceId?: string, // if copied, claimId of original collection
};
@ -17,11 +24,25 @@ declare type CollectionState = {
saved: Array<string>,
isResolvingCollectionById: { [string]: boolean },
error?: string | null,
queue: Collection,
};
declare type CollectionGroup = {
[string]: Collection,
}
};
declare type CollectionList = Array<Collection>;
declare type CollectionCreateParams = {
name: string,
description?: string,
thumbnail?: {
url?: string,
},
items: ?Array<string>,
type: string,
sourceId?: string, // if copied, claimId of original collection
};
declare type CollectionEditParams = {
uris?: Array<string>,
@ -30,4 +51,13 @@ declare type CollectionEditParams = {
order?: { from: number, to: number },
type?: string,
name?: string,
}
description?: string,
thumbnail?: {
url?: string,
},
};
declare type CollectionFetchParams = {
collectionId: string,
pageSize?: number,
};

19
flow-typed/content.js vendored
View file

@ -2,19 +2,16 @@
declare type ContentState = {
primaryUri: ?string,
playingUri: { uri?: string },
playingUri: {
uri?: string,
collection: PlayingCollection,
},
positions: { [string]: { [string]: number } }, // claimId: { outpoint: position }
history: Array<WatchHistory>,
recommendationId: { [string]: string }, // claimId: recommendationId
recommendationParentId: { [string]: string }, // claimId: referrerId
recommendationUrls: { [string]: Array<string> }, // claimId: [lbryUrls...]
recommendationClicks: { [string]: Array<number> }, // "claimId": [clicked indices...]
loopList?: { collectionId: string, loop: boolean },
shuffleList?: { collectionId: string, newUrls: Array<string> | boolean },
// TODO: it's confusing for newUrls to be a boolean --------- ^^^
// It can/should be '?Array<string>` instead -- set it to null, then clients
// can cast it to a boolean. That, or rename the variable to `shuffle` if you
// don't care about the URLs.
lastViewedAnnouncement: ?string, // undefined = not seen in wallet.
recsysEntries: { [ClaimId]: RecsysEntry }, // Persistent shadow copy. The main one resides in RecSys.
};
@ -29,6 +26,12 @@ declare type PlayingUri = {
primaryUri?: string,
pathname?: string,
commentId?: string,
collectionId?: ?string,
collection: PlayingCollection,
source?: string,
};
declare type PlayingCollection = {
collectionId?: ?string,
loop?: ?boolean,
shuffle?: ?{ newUrls: Array<string> },
};

View file

@ -17,6 +17,10 @@ declare type ToastParams = {
linkTarget?: string,
isError?: boolean,
duration?: 'default' | 'long',
actionText?: string,
action?: () => void,
secondaryActionText?: string,
secondaryAction?: () => void,
};
declare type Toast = {

View file

@ -26,9 +26,24 @@
"Tags": "Tags",
"Share": "Share",
"Play": "Play",
"Play All": "Play All",
"Start Playing": "Start Playing",
"Shuffle Play": "Shuffle Play",
"Shuffle": "Shuffle",
"Play in Shuffle mode": "Play in Shuffle mode",
"Loop": "Loop",
"Playlist": "Playlist",
"Visibility": "Visibility",
"Video Count": "Video Count",
"Last updated at": "Last updated at",
"You can add videos to your Playlists": "You can add videos to your Playlists",
"Do you want to find some content to save for later, or create a brand new playlist?": "Do you want to find some content to save for later, or create a brand new playlist?",
"Explore!": "Explore!",
"New Playlist": "New Playlist",
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
"%playlist_item_count% item": "%playlist_item_count% item",
"%playlist_item_count% items": "%playlist_item_count% items",
"Published as: %playlist_channel%": "Published as: %playlist_channel%",
"Report content": "Report content",
"Report Content": "Report Content",
"Report comment": "Report comment",
@ -1173,6 +1188,7 @@
"Paid": "Paid",
"Start at": "Start at",
"Include List ID": "Include List ID",
"Include Playlist ID": "Include Playlist ID",
"Links": "Links",
"Download Link": "Download Link",
"Mature content is not supported.": "Mature content is not supported.",
@ -1775,6 +1791,8 @@
"How does this work?": "How does this work?",
"Introducing Lists": "Introducing Lists",
"You have no lists yet. Better start hoarding!": "You have no lists yet. Better start hoarding!",
"You have no Playlists yet. Better start hoarding!": "You have no Playlists yet. Better start hoarding!",
"Create a Playlist": "Create a Playlist",
"Update your livestream": "Update your livestream",
"Prepare an upcoming livestream": "Prepare an upcoming livestream",
"Edit your post": "Edit your post",
@ -1958,15 +1976,17 @@
"Lists": "Lists",
"Watch Later": "Watch Later",
"Favorites": "Favorites",
"New List": "New List",
"New List Title": "New List Title",
"Add to Lists": "Add to Lists",
"Add to Playlist": "Add to Playlist",
"Playlists": "Playlists",
"Edit List": "Edit List",
"Edit Playlist": "Edit Playlist",
"Delete List": "Delete List",
"Delete Playlist": "Delete Playlist",
"Private": "Private",
"Public": "Public",
"View List": "View List",
"View Playlist": "View Playlist",
"Publish List": "Publish List",
"Info": "Info",
"Publishes": "Publishes",
@ -1979,7 +1999,7 @@
"Credits": "Credits",
"Cannot publish empty list": "Cannot publish empty list",
"MyAwesomeList": "MyAwesomeList",
"My Awesome List": "My Awesome List",
"My Awesome Playlist": "My Awesome Playlist",
"This list has no items.": "This list has no items.",
"1 item": "1 item",
"%collectionCount% items": "%collectionCount% items",
@ -1989,6 +2009,7 @@
"URL Selected": "URL Selected",
"Keep": "Keep",
"Add this claim to a list": "Add this claim to a list",
"Add this video to a playlist": "Add this video to a playlist",
"List is Empty": "List is Empty",
"Confirm List Unpublish": "Confirm List Unpublish",
"This will permanently delete the list.": "This will permanently delete the list.",
@ -2003,8 +2024,8 @@
"Remove from Watch Later": "Remove from Watch Later",
"Add to Watch Later": "Add to Watch Later",
"Added": "Added",
"Item removed from %name%": "Item removed from %name%",
"Item added to %name%": "Item added to %name%",
"Item removed from %playlist_name%": "Item removed from %playlist_name%",
"Item added to %playlist_name%": "Item added to %playlist_name%",
"Item added to Watch Later": "Item added to Watch Later",
"Item removed from Watch Later": "Item removed from Watch Later",
"Item added to Favorites": "Item added to Favorites",
@ -2135,7 +2156,6 @@
"Sending...": "Sending...",
"Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8": "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8",
"Choose %asset%": "Choose %asset%",
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
"filtered": "filtered",
"Failed to synchronize settings. Wait a while before retrying.": "Failed to synchronize settings. Wait a while before retrying.",
"You are offline. Check your internet connection.": "You are offline. Check your internet connection.",
@ -2307,5 +2327,19 @@
"Latest Content Link": "Latest Content Link",
"Current Livestream Link": "Current Livestream Link",
"Back to Odysee Premium": "Back to Odysee Premium",
"Add to Queue": "Add to Queue",
"Queue Mode": "Queue Mode",
"Item added to Queue": "Item added to Queue",
"In Queue": "In Queue",
"Now playing: --[Which Playlist is currently playing]--": "Now playing:",
"Private %lock_icon%": "Private %lock_icon%",
"Playlist is Empty": "Playlist is Empty",
"Queue": "Queue",
"Your Playlists": "Your Playlists",
"Default Playlists": "Default Playlists",
"Open": "Open",
"Support this content": "Support this content",
"Repost this content": "Repost this content",
"Share this content": "Share this content",
"--end--": "--end--"
}

View file

@ -1,8 +1,8 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { withRouter } from 'react-router';
import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
import AutoplayCountdown from './view';
import { selectModal } from 'redux/selectors/app';
import { doOpenModal } from 'redux/actions/app';
/*
AutoplayCountdown does not fetch it's own next content to play, it relies on <RecommendedContent> being rendered.
@ -11,6 +11,11 @@ import { selectModal } from 'redux/selectors/app';
const select = (state, props) => ({
nextRecommendedClaim: makeSelectClaimForUri(props.nextRecommendedUri)(state),
modal: selectModal(state),
isMature: selectClaimIsNsfwForUri(state, props.uri),
});
export default withRouter(connect(select, null)(AutoplayCountdown));
const perform = {
doOpenModal,
};
export default connect(select, perform)(AutoplayCountdown);

View file

@ -6,33 +6,40 @@ import I18nMessage from 'component/i18nMessage';
import { withRouter } from 'react-router';
import debounce from 'util/debounce';
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
const DEBOUNCE_SCROLL_HANDLER_MS = 150;
const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown';
type Props = {
history: { push: (string) => void },
uri?: string,
nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string,
modal: { id: string, modalProps: {} },
skipPaid: boolean,
skipMature: boolean,
isMature: boolean,
doNavigate: () => void,
doReplay: () => void,
doPrevious: () => void,
onCanceled: () => void,
doOpenModal: (id: string, props: {}) => void,
};
function AutoplayCountdown(props: Props) {
const {
uri,
nextRecommendedUri,
nextRecommendedClaim,
history: { push },
modal,
skipPaid,
skipMature,
isMature,
doNavigate,
doReplay,
doPrevious,
onCanceled,
doOpenModal,
} = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -44,6 +51,8 @@ function AutoplayCountdown(props: Props) {
const [timerPaused, setTimerPaused] = React.useState(false);
const anyModalPresent = modal !== undefined && modal !== null;
const isTimerPaused = timerPaused || anyModalPresent;
const shouldSkipMature = skipMature && isMature;
const skipCurrentVideo = skipPaid || shouldSkipMature;
function isAnyInputFocused() {
const activeElement = document.activeElement;
@ -58,7 +67,9 @@ function AutoplayCountdown(props: Props) {
}
function getMsgPlayingNext() {
if (skipPaid) {
if (shouldSkipMature) {
return __('Skipping mature content in %seconds_left% seconds...', { seconds_left: timer });
} else if (skipPaid) {
return __('Playing next free content in %seconds_left% seconds...', { seconds_left: timer });
} else {
return __('Playing in %seconds_left% seconds...', { seconds_left: timer });
@ -89,7 +100,7 @@ function AutoplayCountdown(props: Props) {
interval = setInterval(() => {
const newTime = timer - 1;
if (newTime === 0) {
if (skipPaid) setTimer(countdownTime);
if (skipCurrentVideo) setTimer(countdownTime);
doNavigate();
} else {
setTimer(timer - 1);
@ -100,7 +111,7 @@ function AutoplayCountdown(props: Props) {
return () => {
clearInterval(interval);
};
}, [timer, doNavigate, push, timerCanceled, isTimerPaused, nextRecommendedUri, skipPaid]);
}, [doNavigate, isTimerPaused, nextRecommendedUri, skipCurrentVideo, timer, timerCanceled]);
if (timerCanceled || !nextRecommendedUri) {
return null;
@ -138,7 +149,7 @@ function AutoplayCountdown(props: Props) {
/>
</div>
)}
{skipPaid && doPrevious && (
{skipCurrentVideo && doPrevious && (
<div>
<Button
label={__('Play Previous')}
@ -150,12 +161,16 @@ function AutoplayCountdown(props: Props) {
)}
<div>
<Button
label={skipPaid ? __('Purchase?') : __('Replay?')}
label={shouldSkipMature ? undefined : skipPaid ? __('Purchase?') : __('Replay?')}
button="link"
iconRight={skipPaid ? ICONS.WALLET : ICONS.REPLAY}
icon={shouldSkipMature ? undefined : skipPaid ? ICONS.WALLET : ICONS.REPLAY}
onClick={() => {
setTimerCanceled(true);
doReplay();
if (skipPaid) {
doOpenModal(MODALS.AFFIRM_PURCHASE, { uri, cancelCb: () => setTimerCanceled(false) });
} else {
doReplay();
}
}}
/>
</div>

View file

@ -2,11 +2,13 @@ import Button from './view';
import React, { forwardRef } from 'react';
import { connect } from 'react-redux';
import { selectUser, selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectHasChannels } from 'redux/selectors/claims';
const mapStateToProps = (state) => ({
pathname: state.router.location.pathname,
emailVerified: selectUserVerifiedEmail(state),
user: selectUser(state),
hasChannels: selectHasChannels(state),
});
const ConnectedButton = connect(mapStateToProps)(Button);

View file

@ -36,6 +36,8 @@ type Props = {
pathname: string,
emailVerified: boolean,
requiresAuth: ?boolean,
requiresChannel: ?boolean,
hasChannels: boolean,
myref: any,
dispatch: any,
'aria-label'?: string,
@ -69,6 +71,8 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
activeClass,
emailVerified,
requiresAuth,
requiresChannel,
hasChannels,
myref,
dispatch, // <button> doesn't know what to do with dispatch
pathname,
@ -195,8 +199,12 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
}
}
if (requiresAuth && !emailVerified) {
let redirectUrl = `/$/${PAGES.AUTH}?redirect=${pathname}`;
if ((requiresAuth && !emailVerified) || (requiresChannel && !hasChannels)) {
// requiresChannel can be used for both requiresAuth and requiresChannel,
// since if the button requiresChannel, it also implies it requiresAuth in order to proceed
// so using requiresChannel means: unauth users are sent to signup, auth users to create channel
const page = !emailVerified ? PAGES.AUTH : PAGES.CHANNEL_NEW;
let redirectUrl = `/$/${page}?redirect=${pathname}`;
if (authSrc) {
redirectUrl += `&src=${authSrc}`;

View file

@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { doCollectionEdit } from 'redux/actions/collections';
import { selectCollectionForIdHasClaimUrl, selectUrlsForCollectionId } from 'redux/selectors/collections';
import { selectClaimForUri } from 'redux/selectors/claims';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import ButtonAddToQueue from './view';
import { doToast } from 'redux/actions/notifications';
import { doUriInitiatePlay, doSetPlayingUri } from 'redux/actions/content';
import { selectPlayingUri } from 'redux/selectors/content';
const select = (state, props) => {
const { uri } = props;
const playingUri = selectPlayingUri(state);
const { collectionId } = playingUri.collection || {};
const { permanent_url: playingUrl } = selectClaimForUri(state, playingUri.uri) || {};
return {
playingUri,
playingUrl,
hasClaimInQueue: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, uri),
hasPlayingUriInQueue: Boolean(
playingUrl && selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, playingUrl)
),
playingCollectionUrls:
collectionId && collectionId !== COLLECTIONS_CONSTS.QUEUE_ID && selectUrlsForCollectionId(state, collectionId),
};
};
const perform = {
doToast,
doCollectionEdit,
doUriInitiatePlay,
doSetPlayingUri,
};
export default connect(select, perform)(ButtonAddToQueue);

View file

@ -0,0 +1,97 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import { MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
type Props = {
uri: string,
focusable: boolean,
menuItem?: boolean,
// -- redux --
hasClaimInQueue: boolean,
hasPlayingUriInQueue: boolean,
playingUri: PlayingUri,
playingUrl: ?string,
playingCollectionUrls: ?Array<string>,
doToast: (props: { message: string }) => void,
doCollectionEdit: (id: string, any) => void,
doUriInitiatePlay: (playingOptions: PlayingUri, isPlayable?: boolean, isFloating?: boolean) => void,
doSetPlayingUri: (props: any) => void,
};
function ButtonAddToQueue(props: Props) {
const {
uri,
focusable = true,
menuItem,
hasClaimInQueue,
hasPlayingUriInQueue,
playingUri,
playingUrl,
playingCollectionUrls,
doToast,
doCollectionEdit,
doUriInitiatePlay,
doSetPlayingUri,
} = props;
function handleQueue(e) {
if (e) e.preventDefault();
doToast({ message: hasClaimInQueue ? __('Item removed from Queue') : __('Item added to Queue') });
const itemsToAdd = playingCollectionUrls || [playingUrl];
doCollectionEdit(COLLECTIONS_CONSTS.QUEUE_ID, {
uris: playingUrl && playingUrl !== uri && !hasPlayingUriInQueue ? [...itemsToAdd, uri] : [uri],
remove: hasClaimInQueue,
type: 'playlist',
});
if (!hasClaimInQueue) {
const paramsToAdd = {
collection: { collectionId: COLLECTIONS_CONSTS.QUEUE_ID },
source: COLLECTIONS_CONSTS.QUEUE_ID,
};
if (playingUrl) {
// adds the queue collection id to the playingUri data so it can be used and updated by other components
if (!hasPlayingUriInQueue) doSetPlayingUri({ ...playingUri, ...paramsToAdd });
} else {
// There is nothing playing and added a video to queue -> the first item will play on the floating player with the list open
doUriInitiatePlay({ uri, ...paramsToAdd }, true, true);
}
}
}
// label that is shown after hover
const label = !hasClaimInQueue ? __('Add to Queue') : __('In Queue');
if (menuItem) {
return (
<MenuItem className="comment__menu-option" onSelect={handleQueue}>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInQueue ? ICONS.PLAYLIST_FILLED : ICONS.PLAYLIST} />
{hasClaimInQueue ? __('In Queue') : __('Add to Queue')}
</div>
</MenuItem>
);
}
return (
<div className="claim-preview__hover-actions third-item">
<Button
title={__('Queue Mode')}
label={label}
className="button--file-action"
icon={hasClaimInQueue ? ICONS.PLAYLIST_FILLED : ICONS.PLAYLIST_ADD}
onClick={(e) => handleQueue(e)}
tabIndex={focusable ? 0 : -1}
/>
</div>
);
}
export default ButtonAddToQueue;

View file

@ -17,6 +17,7 @@ import { MINIMUM_PUBLISH_BID, INVALID_NAME_ERROR, ESTIMATED_FEE } from 'constant
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import Card from 'component/common/card';
import * as PAGES from 'constants/pages';
import * as PUBLISH from 'constants/publish';
import analytics from 'analytics';
import LbcSymbol from 'component/common/lbc-symbol';
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
@ -28,8 +29,6 @@ import Gerbil from 'component/channelThumbnail/gerbil.png';
const NEKODEV = false; // Temporary flag to hide unfinished progress
const LANG_NONE = 'none';
const MAX_TAG_SELECT = 5;
type Props = {
@ -189,14 +188,14 @@ function ChannelForm(props: Props) {
function handleLanguageChange(index, code) {
let langs = [...languageParam];
if (index === 0) {
if (code === LANG_NONE) {
if (code === PUBLISH.LANG_NONE) {
// clear all
langs = [];
} else {
langs[0] = code;
}
} else {
if (code === LANG_NONE || code === langs[0]) {
if (code === PUBLISH.LANG_NONE || code === langs[0]) {
langs.splice(1, 1);
} else {
langs[index] = code;
@ -501,7 +500,7 @@ function ChannelForm(props: Props) {
value={primaryLanguage}
helper={__('Your main content language')}
>
<option key={'pri-langNone'} value={LANG_NONE}>
<option key={'pri-langNone'} value={PUBLISH.LANG_NONE}>
{__('None selected')}
</option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
@ -519,7 +518,7 @@ function ChannelForm(props: Props) {
disabled={!languageParam[0]}
helper={__('Your other content language')}
>
<option key={'sec-langNone'} value={LANG_NONE}>
<option key={'sec-langNone'} value={PUBLISH.LANG_NONE}>
{__('None selected')}
</option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (

View file

@ -14,7 +14,7 @@ import PremiumBadge from 'component/premiumBadge';
type Props = {
selectedChannelUrl: string, // currently selected channel
channels: ?Array<ChannelClaim>,
onChannelSelect: (url: string) => void,
onChannelSelect?: (id: ?string) => void,
hideAnon?: boolean,
activeChannelClaim: ?ChannelClaim,
doSetActiveChannel: (claimId: ?string, override?: boolean) => void,
@ -38,6 +38,7 @@ export default function ChannelSelector(props: Props) {
channels,
activeChannelClaim,
doSetActiveChannel,
onChannelSelect,
incognito,
doSetIncognito,
odyseeMembershipByUri,
@ -63,11 +64,14 @@ export default function ChannelSelector(props: Props) {
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
function handleChannelSelect(channelClaim) {
const { claim_id: id } = channelClaim;
doSetIncognito(false);
doSetActiveChannel(channelClaim.claim_id);
doSetActiveChannel(id);
if (onChannelSelect) onChannelSelect(id);
if (storeSelection) {
doSetDefaultChannel(channelClaim.claim_id);
doSetDefaultChannel(id);
}
}
@ -138,7 +142,12 @@ export default function ChannelSelector(props: Props) {
</MenuItem>
))}
{!hideAnon && (
<MenuItem onSelect={() => doSetIncognito(true)}>
<MenuItem
onSelect={() => {
doSetIncognito(true);
if (onChannelSelect) onChannelSelect(undefined);
}}
>
<IncognitoSelector />
</MenuItem>
)}

View file

@ -4,7 +4,7 @@ import { parseURI } from 'util/lbryURI';
import { getImageProxyUrl } from 'util/thumbnail';
import classnames from 'classnames';
import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
import OptimizedImage from 'component/optimizedImage';
import { AVATAR_DEFAULT } from 'config';
import useGetUserMemberships from 'effects/use-get-user-memberships';
@ -98,12 +98,19 @@ function ChannelThumbnail(props: Props) {
if (isGif && !allowGifs) {
const url = getImageProxyUrl(channelThumbnail);
return (
<FreezeframeWrapper
src={url}
className={classnames('channel-thumbnail', className, { 'channel-thumbnail--xxsmall': xxsmall })}
>
{showMemberBadge && <PremiumBadge {...badgeProps} />}
</FreezeframeWrapper>
url && (
<FreezeframeWrapper
src={url}
className={classnames('channel-thumbnail', className, {
'channel-thumbnail--small': small,
'channel-thumbnail--xsmall': xsmall,
'channel-thumbnail--xxsmall': xxsmall,
'channel-thumbnail--resolving': isResolving,
})}
>
{showMemberBadge ? <PremiumBadge {...badgeProps} /> : null}
</FreezeframeWrapper>
)
);
}

View file

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import ClaimCollectionAdd from './view';
import { withRouter } from 'react-router';
import {
selectBuiltinCollections,
selectMyPublishedCollections,
selectMyUnpublishedCollections,
} from 'redux/selectors/collections';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { doLocalCollectionCreate } from 'redux/actions/collections';
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

@ -1,148 +0,0 @@
// @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 * as KEYCODES from 'constants/keycodes';
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('');
setAddNewCollection(false);
}
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ENTER) {
e.preventDefault();
buttonref.current.click();
}
}
function onTextareaFocus() {
window.addEventListener('keydown', altEnterListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', altEnterListener);
}
function handleClearNew() {
setNewCollectionName('');
setAddNewCollection(false);
}
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={'alt'}
icon={ICONS.ADD}
className={'button-toggle'}
disabled={!newCollectionName.length}
onClick={() => handleAddCollection()}
ref={buttonref}
/>
<Button
button={'alt'}
className={'button-toggle'}
icon={ICONS.REMOVE}
onClick={() => handleClearNew()}
/>
</>
}
onChange={handleNameInput}
/>
)}
{!addNewCollection && (
<Button button={'link'} label={__('New List')} onClick={() => setAddNewCollection(true)} />
)}
</fieldset-section>
<div className="card__actions">
<Button button="primary" label={__('Done')} disabled={addNewCollection} onClick={closeModal} />
</div>
</div>
}
/>
);
};
export default ClaimCollectionAdd;

View file

@ -2,20 +2,17 @@ import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import CollectionAddButton from './view';
import { selectClaimForUri } from 'redux/selectors/claims';
import { makeSelectClaimUrlInCollection } from 'redux/selectors/collections';
import { selectClaimSavedForUrl } from 'redux/selectors/collections';
const select = (state, props) => {
const { uri } = props;
const claim = selectClaimForUri(state, uri);
// $FlowFixMe
const { permanent_url: permanentUrl, value } = claim || {};
const streamType = (value && value.stream_type) || '';
const { permanent_url: permanentUrl, value } = selectClaimForUri(state, uri) || {};
const { stream_type: streamType } = value || {};
return {
streamType,
isSaved: permanentUrl && makeSelectClaimUrlInCollection(permanentUrl)(state),
isSaved: permanentUrl && selectClaimSavedForUrl(state, permanentUrl),
};
};

View file

@ -2,40 +2,33 @@
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';
import Tooltip from 'component/common/tooltip';
import FileActionButton from 'component/common/file-action-button';
type Props = {
uri: string,
fileAction?: boolean,
type?: boolean,
// redux
streamType: Claim,
isSaved: boolean,
doOpenModal: (id: string, {}) => void,
};
export default function CollectionAddButton(props: Props) {
const { uri, fileAction, type = 'playlist', isSaved, streamType, doOpenModal } = props;
function ClaimCollectionAddButton(props: Props) {
const { uri, streamType, isSaved, doOpenModal } = props;
const isPlayable = streamType === 'video' || streamType === 'audio';
return !isPlayable ? null : (
<Tooltip title={__('Add this claim to a list')} arrow={false}>
<Button
button={!fileAction ? 'alt' : undefined}
className={classnames({ 'button--file-action': fileAction })}
icon={fileAction ? (!isSaved ? ICONS.ADD : ICONS.STACK) : ICONS.LIBRARY}
iconSize={fileAction ? 16 : undefined}
label={uri ? (!isSaved ? __('Save') : __('Saved')) : __('New List')}
requiresAuth
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
doOpenModal(MODALS.COLLECTION_ADD, { uri, type });
}}
/>
</Tooltip>
if (!isPlayable) return null;
return (
<FileActionButton
title={__('Add this video to a playlist')}
label={!isSaved ? __('Save') : __('Saved')}
icon={!isSaved ? ICONS.PLAYLIST_ADD : ICONS.PLAYLIST_FILLED}
iconSize={20}
requiresAuth
onClick={() => doOpenModal(MODALS.COLLECTION_ADD, { uri })}
/>
);
}
export default ClaimCollectionAddButton;

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { selectClaimForUri } from 'redux/selectors/claims';
import ClaimDescription from './view';
import { getClaimMetadata } from 'util/claim';
const select = (state, props) => {
const { uri, description: descriptionProp } = props;
const claim = !descriptionProp && selectClaimForUri(state, uri);
const metadata = claim && getClaimMetadata(claim);
return {
description: descriptionProp || (metadata && metadata.description),
};
};
export default connect(select)(ClaimDescription);

View file

@ -0,0 +1,17 @@
// @flow
import React from 'react';
import MarkdownPreview from 'component/common/markdown-preview';
type Props = {
description?: string,
};
function ClaimDescription(props: Props) {
const { description } = props;
return !description ? null : (
<MarkdownPreview className="markdown-preview--description" content={description} simpleLinks />
);
}
export default ClaimDescription;

View file

@ -10,6 +10,8 @@ import usePersistedState from 'effects/use-persisted-state';
import useGetLastVisibleSlot from 'effects/use-get-last-visible-slot';
import debounce from 'util/debounce';
import ClaimPreviewTile from 'component/claimPreviewTile';
import Button from 'component/button';
import { useIsMobile } from 'effects/use-screensize';
const Draggable = React.lazy(() =>
// $FlowFixMe
@ -61,7 +63,16 @@ type Props = {
unavailableUris?: Array<string>,
showMemberBadge?: boolean,
inWatchHistory?: boolean,
smallThumbnail?: boolean,
showIndexes?: boolean,
playItemsOnClick?: boolean,
disableClickNavigation?: boolean,
setActiveListItemRef?: any,
setListRef?: any,
onHidden: (string) => void,
doDisablePlayerDrag?: (disable: boolean) => void,
restoreScrollPos?: () => void,
setHasActive?: (has: boolean) => void,
};
export default function ClaimList(props: Props) {
@ -103,12 +114,24 @@ export default function ClaimList(props: Props) {
unavailableUris,
showMemberBadge,
inWatchHistory,
smallThumbnail,
showIndexes,
playItemsOnClick,
disableClickNavigation,
setActiveListItemRef,
setListRef,
onHidden,
doDisablePlayerDrag,
restoreScrollPos,
setHasActive,
} = props;
const isMobile = useIsMobile();
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
const uriBuffer = React.useRef([]);
const currentActiveItem = React.useRef();
// Resolve the index for injectedItem, if provided; else injectedIndex will be 'undefined'.
const listRef = React.useRef();
const findLastVisibleSlot = injectedItem && injectedItem.node && injectedItem.index === undefined;
@ -201,9 +224,15 @@ export default function ClaimList(props: Props) {
swipeLayout={swipeLayout}
showEdit={showEdit}
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
wrapperElement={draggableProvided ? 'div' : undefined}
unavailableUris={unavailableUris}
showMemberBadge={showMemberBadge}
inWatchHistory={inWatchHistory}
smallThumbnail={smallThumbnail}
showIndexes={showIndexes}
playItemsOnClick={playItemsOnClick}
disableClickNavigation={disableClickNavigation}
doDisablePlayerDrag={doDisablePlayerDrag}
/>
);
@ -223,6 +252,40 @@ export default function ClaimList(props: Props) {
return null;
};
React.useEffect(() => {
if (setHasActive) {
// used in case the active item is deleted
setHasActive(sortedUris.some((uri) => activeUri && uri === activeUri));
}
}, [activeUri, setHasActive, sortedUris]);
const listRefCb = React.useCallback(
(node) => {
if (node) {
if (droppableProvided) droppableProvided.innerRef(node);
if (setListRef) setListRef(node);
}
},
[droppableProvided, setListRef]
);
const listItemCb = React.useCallback(
({ node, isActive, draggableProvidedRef }) => {
if (node) {
if (draggableProvidedRef) draggableProvidedRef(node);
// currentActiveItem.current !== node prevents re-scrolling during the same render
// so it should only auto scroll when the active item switches, the button to scroll is clicked
// or the list itself changes (switch between floating player vs file page)
if (isActive && setActiveListItemRef && currentActiveItem.current !== node) {
setActiveListItemRef(node);
currentActiveItem.current = node;
}
}
},
[setActiveListItemRef]
);
return tileLayout && !header ? (
<>
<section ref={listRef} className={classnames('claim-grid', { 'swipe-list': swipeLayout })}>
@ -269,11 +332,7 @@ export default function ClaimList(props: Props) {
)}
</>
) : (
<section
className={classnames('claim-list', {
'claim-list--small': type === 'small',
})}
>
<section className={classnames('claim-list', { 'claim-list--no-margin': showIndexes })}>
{header !== false && (
<React.Fragment>
{header && (
@ -311,7 +370,7 @@ export default function ClaimList(props: Props) {
'swipe-list': swipeLayout,
})}
{...(droppableProvided && droppableProvided.droppableProps)}
ref={droppableProvided ? droppableProvided.innerRef : listRef}
ref={listRefCb}
>
{droppableProvided ? (
<>
@ -326,13 +385,46 @@ export default function ClaimList(props: Props) {
transform = transform.replace(/\(.+,/, '(0,');
}
// doDisablePlayerDrag is a function brought by fileRenderFloating if is floating
const isDraggingFromFloatingPlayer = draggableSnapshot.isDragging && doDisablePlayerDrag;
const isDraggingFromMobile = draggableSnapshot.isDragging && isMobile;
const topForDrawer = Number(
// $FlowFixMe
document.documentElement?.style?.getPropertyValue('--content-height') || 0
);
const playerInfo = isDraggingFromFloatingPlayer && document.querySelector('.content__info');
const playerElem = isDraggingFromFloatingPlayer && document.querySelector('.content__viewer');
const playerTransform = playerElem && playerElem.style.transform;
let playerTop =
playerTransform &&
Number(
playerTransform.substring(playerTransform.indexOf(', ') + 2, playerTransform.indexOf('px)'))
);
if (playerElem && navigator.userAgent.toLowerCase().indexOf('firefox') !== -1) {
playerTop -= playerElem.offsetHeight;
}
const style = {
...draggableProvided.draggableProps.style,
transform,
top: isDraggingFromFloatingPlayer
? draggableProvided.draggableProps.style.top - playerInfo?.offsetTop - Number(playerTop)
: isDraggingFromMobile
? draggableProvided.draggableProps.style.top - topForDrawer
: draggableProvided.draggableProps.style.top,
left: isDraggingFromFloatingPlayer ? undefined : draggableProvided.draggableProps.style.left,
right: isDraggingFromFloatingPlayer ? undefined : draggableProvided.draggableProps.style.right,
};
const isActive = activeUri && uri === activeUri;
return (
<li ref={draggableProvided.innerRef} {...draggableProvided.draggableProps} style={style}>
<li
ref={(node) =>
listItemCb({ node, isActive, draggableProvidedRef: draggableProvided.innerRef })
}
{...draggableProvided.draggableProps}
style={style}
>
{/* https://github.com/atlassian/react-beautiful-dnd/issues/1756 */}
<div style={{ display: 'none' }} {...draggableProvided.dragHandleProps} />
{getClaimPreview(uri, index, draggableProvided)}
@ -355,6 +447,15 @@ export default function ClaimList(props: Props) {
</ul>
)}
{restoreScrollPos && (
<Button
button="secondary"
className="claim-list__scroll-to-recent"
label={__('Scroll to Playing')}
onClick={restoreScrollPos}
/>
)}
{!timedOut && urisLength === 0 && !loading && !noEmpty && (
<div className="empty empty--centered">{empty || noResultMsg}</div>
)}

View file

@ -374,7 +374,7 @@ function ClaimListHeader(props: Props) {
return (
<option key={type} value={type}>
{/* i18fixme */}
{type === CS.CLAIM_COLLECTION && __('List')}
{type === CS.CLAIM_COLLECTION && __('Playlist')}
{type === CS.CLAIM_CHANNEL && __('Channel')}
{type === CS.CLAIM_REPOST && __('Repost')}
{type === CS.FILE_VIDEO && __('Video')}

View file

@ -1,15 +1,16 @@
import { connect } from 'react-redux';
import { selectClaimForUri, selectClaimIsMine } from 'redux/selectors/claims';
import { doCollectionEdit, doFetchItemsInCollection } from 'redux/actions/collections';
import { doFetchItemsInCollection } from 'redux/actions/collections';
import { doPrepareEdit } from 'redux/actions/publish';
import { doRemovePersonalRecommendation } from 'redux/actions/search';
import {
makeSelectCollectionForId,
makeSelectCollectionForIdHasClaimUrl,
makeSelectCollectionIsMine,
makeSelectEditedCollectionForId,
makeSelectUrlsForCollectionId,
selectCollectionForId,
selectCollectionForIdHasClaimUrl,
selectCollectionIsMine,
selectCollectionHasEditsForId,
selectUrlsForCollectionId,
selectLastUsedCollection,
selectCollectionIsEmptyForId,
} from 'redux/selectors/collections';
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
import * as COLLECTIONS_CONSTS from 'constants/collections';
@ -31,8 +32,8 @@ import { doToast } from 'redux/actions/notifications';
import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectListShuffle, makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
import { selectListShuffleForId, makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import { doToggleShuffleList, doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
import { isStreamPlaceholderClaim } from 'util/claim';
import * as RENDER_MODES from 'constants/file_render_modes';
import ClaimPreview from './view';
@ -46,11 +47,10 @@ const select = (state, props) => {
const contentSigningChannel = contentClaim && contentClaim.signing_channel;
const contentPermanentUri = contentClaim && contentClaim.permanent_url;
const contentChannelUri = (contentSigningChannel && contentSigningChannel.permanent_url) || contentPermanentUri;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
const collectionShuffle = selectListShuffleForId(state, collectionId);
const playNextUri = collectionShuffle && collectionShuffle.newUrls[0];
const lastUsedCollectionId = selectLastUsedCollection(state);
const lastUsedCollection = makeSelectCollectionForId(lastUsedCollectionId)(state);
const lastUsedCollection = lastUsedCollectionId && selectCollectionForId(state, lastUsedCollectionId);
const isLivestreamClaim = isStreamPlaceholderClaim(claim);
const permanentUrl = (claim && claim.permanent_url) || '';
const isPostClaim = makeSelectFileRenderModeForUri(permanentUrl)(state) === RENDER_MODES.MARKDOWN;
@ -64,34 +64,30 @@ const select = (state, props) => {
isLivestreamClaim,
isPostClaim,
claimIsMine: selectClaimIsMine(state, claim),
hasClaimInWatchLater: makeSelectCollectionForIdHasClaimUrl(
hasClaimInWatchLater: selectCollectionForIdHasClaimUrl(
state,
COLLECTIONS_CONSTS.WATCH_LATER_ID,
contentPermanentUri
)(state),
hasClaimInFavorites: makeSelectCollectionForIdHasClaimUrl(
COLLECTIONS_CONSTS.FAVORITES_ID,
contentPermanentUri
)(state),
),
hasClaimInFavorites: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.FAVORITES_ID, contentPermanentUri),
channelIsMuted: makeSelectChannelIsMuted(contentChannelUri)(state),
channelIsBlocked: makeSelectChannelIsBlocked(contentChannelUri)(state),
fileInfo: makeSelectFileInfoForUri(contentPermanentUri)(state),
isSubscribed: selectIsSubscribedForUri(state, contentChannelUri),
channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state),
isAdmin: selectHasAdminChannel(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(collectionId, contentPermanentUri)(state),
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
claimInCollection: selectCollectionForIdHasClaimUrl(state, collectionId, contentPermanentUri),
isMyCollection: selectCollectionIsMine(state, collectionId),
hasEdits: selectCollectionHasEditsForId(state, collectionId),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
resolvedList: makeSelectUrlsForCollectionId(collectionId)(state),
resolvedList: selectUrlsForCollectionId(state, collectionId),
playNextUri,
lastUsedCollection,
hasClaimInLastUsedCollection: makeSelectCollectionForIdHasClaimUrl(
lastUsedCollectionId,
contentPermanentUri
)(state),
hasClaimInLastUsedCollection: selectCollectionForIdHasClaimUrl(state, lastUsedCollectionId, contentPermanentUri),
lastUsedCollectionIsNotBuiltin:
lastUsedCollectionId !== COLLECTIONS_CONSTS.WATCH_LATER_ID &&
lastUsedCollectionId !== COLLECTIONS_CONSTS.FAVORITES_ID,
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
};
};
@ -108,13 +104,10 @@ const perform = (dispatch) => ({
dispatch(doCommentModUnBlockAsAdmin(commenterUri, blockerId)),
doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)),
doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)),
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),
fetchCollectionItems: (collectionId) => dispatch(doFetchItemsInCollection({ collectionId })),
doToggleShuffleList: (collectionId) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
},
doToggleShuffleList: (params) => dispatch(doToggleShuffleList(params)),
doRemovePersonalRecommendation: (uri) => dispatch(doRemovePersonalRecommendation(uri)),
doPlaylistAddAndAllowPlaying: (params) => dispatch(doPlaylistAddAndAllowPlaying(params)),
});
export default connect(select, perform)(ClaimPreview);

View file

@ -7,6 +7,7 @@ import * as COLLECTIONS_CONSTS from 'constants/collections';
import React from 'react';
import classnames from 'classnames';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import { PUBLISH_PAGE, EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
import Icon from 'component/common/icon';
import {
generateShareUrl,
@ -17,10 +18,9 @@ import {
} from 'util/url';
import { useHistory } from 'react-router';
import { buildURI, parseURI } from 'util/lbryURI';
import ButtonAddToQueue from 'component/buttonAddToQueue';
const SHARE_DOMAIN = SHARE_DOMAIN_URL || URL;
const PAGE_VIEW_QUERY = 'view';
const EDIT_PAGE = 'edit';
type SubscriptionArgs = {
channelName: string,
@ -47,7 +47,6 @@ type Props = {
doCommentModUnBlock: (string) => void,
doCommentModBlockAsAdmin: (commenterUri: string, offendingCommentId: ?string, blockerId: ?string) => void,
doCommentModUnBlockAsAdmin: (string, string) => void,
doCollectionEdit: (string, any) => void,
hasClaimInWatchLater: boolean,
hasClaimInFavorites: boolean,
claimInCollection: boolean,
@ -56,7 +55,7 @@ type Props = {
isLivestreamClaim?: boolean,
isPostClaim?: boolean,
fypId?: string,
doToast: ({ message: string, isError?: boolean }) => void,
doToast: ({ message: string, isError?: boolean, linkText?: string, linkTarget?: string }) => void,
claimIsMine: boolean,
fileInfo: FileListItem,
prepareEdit: ({}, string, string) => void,
@ -64,16 +63,23 @@ type Props = {
doChannelSubscribe: (SubscriptionArgs) => void,
doChannelUnsubscribe: (SubscriptionArgs) => void,
isChannelPage: boolean,
editedCollection: Collection,
hasEdits: Collection,
isAuthenticated: boolean,
playNextUri: string,
resolvedList: boolean,
fetchCollectionItems: (string) => void,
doToggleShuffleList: (string) => void,
doToggleShuffleList: (params: { currentUri?: string, collectionId: string, hideToast?: boolean }) => void,
lastUsedCollection: ?Collection,
hasClaimInLastUsedCollection: boolean,
lastUsedCollectionIsNotBuiltin: boolean,
doRemovePersonalRecommendation: (uri: string) => void,
collectionEmpty: boolean,
doPlaylistAddAndAllowPlaying: (params: {
uri: string,
collectionName: string,
collectionId: string,
push: (uri: string) => void,
}) => void,
};
function ClaimMenuList(props: Props) {
@ -96,7 +102,6 @@ function ClaimMenuList(props: Props) {
doCommentModUnBlock,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsAdmin,
doCollectionEdit,
hasClaimInWatchLater,
hasClaimInFavorites,
collectionId,
@ -112,7 +117,7 @@ function ClaimMenuList(props: Props) {
doChannelSubscribe,
doChannelUnsubscribe,
isChannelPage = false,
editedCollection,
hasEdits,
isAuthenticated,
playNextUri,
resolvedList,
@ -122,8 +127,16 @@ function ClaimMenuList(props: Props) {
hasClaimInLastUsedCollection,
lastUsedCollectionIsNotBuiltin,
doRemovePersonalRecommendation,
collectionEmpty,
doPlaylistAddAndAllowPlaying,
} = props;
const {
push,
replace,
location: { search },
} = useHistory();
const [doShuffle, setDoShuffle] = React.useState(false);
const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@');
const isChannel = !incognitoClaim && !contentSigningChannel;
@ -137,7 +150,6 @@ function ClaimMenuList(props: Props) {
? __('Unfollow')
: __('Follow');
const { push, replace } = useHistory();
const claimType = isLivestreamClaim ? 'livestream' : isPostClaim ? 'post' : 'upload';
const fetchItems = React.useCallback(() => {
@ -148,7 +160,7 @@ function ClaimMenuList(props: Props) {
React.useEffect(() => {
if (doShuffle && resolvedList) {
doToggleShuffleList(collectionId);
doToggleShuffleList({ collectionId });
if (playNextUri) {
const navigateUrl = formatLbryUrlForWeb(playNextUri);
push({
@ -178,12 +190,24 @@ function ClaimMenuList(props: Props) {
// $FlowFixMe
(contentClaim.value.stream_type === 'audio' || contentClaim.value.stream_type === 'video');
function handleAdd(source, name, collectionId) {
doToast({
message: source ? __('Item removed from %name%', { name }) : __('Item added to %name%', { name }),
});
if (contentClaim) {
doCollectionEdit(collectionId, { uris: [contentClaim.permanent_url], remove: source, type: 'playlist' });
function handleAdd(claimIsInPlaylist, name, collectionId) {
const itemUrl = contentClaim?.canonical_url;
if (itemUrl) {
const urlParams = new URLSearchParams(search);
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
doPlaylistAddAndAllowPlaying({
uri: itemUrl,
collectionName: name,
collectionId,
push: (pushUri) =>
push({
pathname: formatLbryUrlForWeb(pushUri),
search: urlParams.toString(),
state: { collectionId, forceAutoplay: true },
}),
});
}
}
@ -230,7 +254,7 @@ function ClaimMenuList(props: Props) {
prepareEdit(claim, editUri, claimType);
} else {
const channelUrl = claim.name + ':' + claim.claim_id;
push(`/${channelUrl}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`);
push(`/${channelUrl}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`);
}
}
@ -278,7 +302,6 @@ function ClaimMenuList(props: Props) {
push(`/$/${PAGES.REPORT_CONTENT}?claimId=${contentClaim && contentClaim.claim_id}`);
}
const shouldShow = !IS_WEB || (IS_WEB && isAuthenticated);
return (
<Menu>
<MenuButton
@ -308,33 +331,46 @@ function ClaimMenuList(props: Props) {
{/* COLLECTION OPERATIONS */}
{collectionId && isCollectionClaim ? (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.PLAYLIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
{__('View Playlist')}
</a>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => {
if (!resolvedList) fetchItems();
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
{!collectionEmpty && (
<MenuItem
className="comment__menu-option"
onSelect={() => {
if (!resolvedList) fetchItems();
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
)}
{isMyCollection && (
<>
{!collectionEmpty && (
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`)}
>
<div className="menu__link">
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
{hasEdits ? __('Publish') : __('Update')}
</div>
</MenuItem>
)}
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
>
<div className="menu__link">
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
{editedCollection ? __('Publish') : __('Edit List')}
<Icon aria-hidden icon={ICONS.EDIT} />
{__('Edit')}
</div>
</MenuItem>
<MenuItem
@ -343,69 +379,78 @@ function ClaimMenuList(props: Props) {
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete List')}
{__('Delete Playlist')}
</div>
</MenuItem>
</>
)}
</>
) : (
shouldShow &&
isPlayable && (
<>
{/* WATCH LATER */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
{hasClaimInWatchLater ? __('In Watch Later') : __('Watch Later')}
</div>
</MenuItem>
{/* FAVORITES LIST */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInFavorites, __('Favorites'), COLLECTIONS_CONSTS.FAVORITES_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInFavorites ? ICONS.DELETE : ICONS.STAR} />
{hasClaimInFavorites ? __('In Favorites') : __('Favorites')}
</div>
</MenuItem>
{/* CURRENTLY ONLY SUPPORT PLAYLISTS FOR PLAYABLE; LATER DIFFERENT TYPES */}
<MenuItem
className="comment__menu-option"
onSelect={() => openModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.STACK} />
{__('Add to Lists')}
</div>
</MenuItem>
{lastUsedCollection && lastUsedCollectionIsNotBuiltin && (
<MenuItem
className="comment__menu-option"
onSelect={() =>
handleAdd(hasClaimInLastUsedCollection, lastUsedCollection.name, lastUsedCollection.id)
}
>
<div className="menu__link">
{!hasClaimInLastUsedCollection && <Icon aria-hidden icon={ICONS.ADD} />}
{hasClaimInLastUsedCollection && <Icon aria-hidden icon={ICONS.DELETE} />}
{!hasClaimInLastUsedCollection &&
__('Add to %collection%', { collection: lastUsedCollection.name })}
{hasClaimInLastUsedCollection && __('In %collection%', { collection: lastUsedCollection.name })}
</div>
</MenuItem>
{/* QUEUE */}
{contentClaim && <ButtonAddToQueue uri={contentClaim.permanent_url} menuItem />}
{isAuthenticated && (
<>
{/* WATCH LATER */}
<MenuItem
className="comment__menu-option"
onSelect={() =>
handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)
}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
{hasClaimInWatchLater ? __('In Watch Later') : __('Watch Later')}
</div>
</MenuItem>
{/* FAVORITES LIST */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInFavorites, __('Favorites'), COLLECTIONS_CONSTS.FAVORITES_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInFavorites ? ICONS.DELETE : ICONS.STAR} />
{hasClaimInFavorites ? __('In Favorites') : __('Favorites')}
</div>
</MenuItem>
{/* CURRENTLY ONLY SUPPORT PLAYLISTS FOR PLAYABLE; LATER DIFFERENT TYPES */}
<MenuItem
className="comment__menu-option"
onSelect={() => openModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.PLAYLIST_ADD} />
{__('Add to Playlist')}
</div>
</MenuItem>
{lastUsedCollection && lastUsedCollectionIsNotBuiltin && (
<MenuItem
className="comment__menu-option"
onSelect={() =>
handleAdd(hasClaimInLastUsedCollection, lastUsedCollection.name, lastUsedCollection.id)
}
>
<div className="menu__link">
{!hasClaimInLastUsedCollection && <Icon aria-hidden icon={ICONS.ADD} />}
{hasClaimInLastUsedCollection && <Icon aria-hidden icon={ICONS.DELETE} />}
{!hasClaimInLastUsedCollection &&
__('Add to %collection%', { collection: lastUsedCollection.name })}
{hasClaimInLastUsedCollection &&
__('In %collection%', { collection: lastUsedCollection.name })}
</div>
</MenuItem>
)}
<hr className="menu__separator" />
</>
)}
<hr className="menu__separator" />
</>
)
)}
</>
{shouldShow && (
{isAuthenticated && (
<>
{!isChannelPage && (
<>

View file

@ -10,7 +10,7 @@ import {
selectGeoRestrictionForUri,
} from 'redux/selectors/claims';
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
import { makeSelectCollectionIsMine } from 'redux/selectors/collections';
import { selectCollectionIsMine } from 'redux/selectors/collections';
import { doResolveUri } from 'redux/actions/claims';
import { doFileGet } from 'redux/actions/file';
@ -22,7 +22,7 @@ import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { isClaimNsfw, isStreamPlaceholderClaim } from 'util/claim';
import ClaimPreview from './view';
import formatMediaDuration from 'util/formatMediaDuration';
import { doClearContentHistoryUri } from 'redux/actions/content';
import { doClearContentHistoryUri, doUriInitiatePlay } from 'redux/actions/content';
const select = (state, props) => {
const claim = props.uri && selectClaimForUri(state, props.uri);
@ -50,7 +50,7 @@ const select = (state, props) => {
isLivestream,
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state),
isCollectionMine: selectCollectionIsMine(state, props.collectionId),
lang: selectLanguage(state),
};
};
@ -59,6 +59,8 @@ const perform = (dispatch) => ({
resolveUri: (uri) => dispatch(doResolveUri(uri)),
getFile: (uri) => dispatch(doFileGet(uri, false)),
doClearContentHistoryUri: (uri) => dispatch(doClearContentHistoryUri(uri)),
doUriInitiatePlay: (playingOptions, isPlayable, isFloating) =>
dispatch(doUriInitiatePlay(playingOptions, isPlayable, isFloating)),
});
export default connect(select, perform)(ClaimPreview);

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import { doCollectionEdit } from 'redux/actions/collections';
import ButtonAddToQueue from './view';
import { doToast } from 'redux/actions/notifications';
const perform = {
doToast,
doCollectionEdit,
};
export default connect(null, perform)(ButtonAddToQueue);

View file

@ -0,0 +1,40 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
type Props = {
uri: string,
collectionId: string,
focusable: boolean,
// -- redux --
doToast: (props: { message: string }) => void,
doCollectionEdit: (id: string, any) => void,
};
function ButtonAddToQueue(props: Props) {
const { uri, collectionId, focusable = true, doToast, doCollectionEdit } = props;
function handleRemove(e) {
if (e) e.preventDefault();
doToast({ message: __('Item removed') });
doCollectionEdit(collectionId, { uris: [uri], remove: true, type: 'playlist' });
}
return (
<div className="claim-preview__hover-actions third-item">
<Button
title={__('Remove')}
label={__('Remove')}
className="button--file-action"
icon={ICONS.DELETE}
onClick={(e) => handleRemove(e)}
tabIndex={focusable ? 0 : -1}
/>
</div>
);
}
export default ButtonAddToQueue;

View file

@ -2,20 +2,25 @@
import classnames from 'classnames';
import React from 'react';
import Empty from 'component/common/empty';
import ButtonRemoveFromCollection from './buttonRemoveFromCollection';
type Props = {
uri?: string,
collectionId?: ?string,
isChannel: boolean,
type: string,
message: string,
};
function ClaimPreviewHidden(props: Props) {
const { isChannel, type, message } = props;
const { uri, collectionId, isChannel, type, message } = props;
return (
<li
className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
'claim-preview__wrapper--small': type === 'small',
})}
>
<div
@ -23,7 +28,14 @@ function ClaimPreviewHidden(props: Props) {
'claim-preview--large': type === 'large',
})}
>
<div className="media__thumb" />
<div className={classnames('media__thumb', { 'media__thumb--small': type === 'small' })}>
{collectionId && (
<div className="claim-preview__hover-actions-grid">
<ButtonRemoveFromCollection uri={uri} collectionId={collectionId} />
</div>
)}
</div>
<Empty text={message} />
</div>
</li>

View file

@ -25,18 +25,19 @@ import useGetThumbnail from 'effects/use-get-thumbnail';
import ClaimPreviewTitle from 'component/claimPreviewTitle';
import ClaimPreviewSubtitle from 'component/claimPreviewSubtitle';
import ClaimRepostAuthor from 'component/claimRepostAuthor';
import FileDownloadLink from 'component/fileDownloadLink';
import FileWatchLaterLink from 'component/fileWatchLaterLink';
import PublishPending from 'component/publish/shared/publishPending';
import ButtonAddToQueue from 'component/buttonAddToQueue';
import ClaimMenuList from 'component/claimMenuList';
import ClaimPreviewReset from 'component/claimPreviewReset';
import ClaimPreviewLoading from './claim-preview-loading';
import ClaimPreviewHidden from './claim-preview-no-mature';
import ClaimPreviewNoContent from './claim-preview-no-content';
import ClaimPreviewLoading from 'component/common/claim-preview-loading';
import ClaimPreviewHidden from './internal/claim-preview-no-mature';
import ClaimPreviewNoContent from './internal/claim-preview-no-content';
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import CollectionEditButtons from 'component/collectionEditButtons';
import * as ICONS from 'constants/icons';
import { useIsMobile } from 'effects/use-screensize';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
const AbandonedChannelPreview = lazyImport(() =>
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
@ -54,7 +55,7 @@ type Props = {
reflectingProgress?: any, // fxme
resolveUri: (string) => void,
isResolvingUri: boolean,
history: { push: (string | any) => void },
history: { push: (string | any) => void, location: { pathname: string, search: string } },
title: string,
nsfw: boolean,
placeholder: string,
@ -98,7 +99,13 @@ type Props = {
unavailableUris?: Array<string>,
showMemberBadge?: boolean,
inWatchHistory?: boolean,
smallThumbnail?: boolean,
showIndexes?: boolean,
playItemsOnClick?: boolean,
disableClickNavigation?: boolean,
doClearContentHistoryUri: (uri: string) => void,
doUriInitiatePlay: (playingOptions: PlayingUri, isPlayable?: boolean, isFloating?: boolean) => void,
doDisablePlayerDrag?: (disable: boolean) => void,
};
const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
@ -166,11 +173,22 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
unavailableUris,
showMemberBadge,
inWatchHistory,
smallThumbnail,
showIndexes,
playItemsOnClick,
disableClickNavigation,
doClearContentHistoryUri,
doUriInitiatePlay,
doDisablePlayerDrag,
} = props;
const isMobile = useIsMobile();
const {
location: { pathname, search },
} = history;
const playlistPreviewItem = unavailableUris !== undefined || showIndexes;
const isCollection = claim && claim.value_type === 'collection';
const collectionClaimId = isCollection && claim && claim.claim_id;
const listId = collectionId || collectionClaimId;
@ -182,7 +200,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
if (isMyCollection && claim === null && unavailableUris) unavailableUris.push(uri);
const shouldHideActions = hideActions || isMyCollection || type === 'small' || type === 'tooltip';
const canonicalUrl = claim && claim.canonical_url;
const channelSubscribers = React.useMemo(() => {
if (channelSubCount === undefined) {
return <span />;
@ -244,6 +261,17 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
}
const handleNavLinkClick = (e) => {
if (playItemsOnClick && claim) {
doUriInitiatePlay(
{
uri: claim?.canonical_url || uri,
collection: { collectionId },
source: collectionId === 'queue' ? collectionId : undefined,
},
true,
disableClickNavigation
);
}
if (onClick) {
onClick(e, claim, indexInContainer); // not sure indexInContainer is used for anything.
}
@ -252,8 +280,8 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
const navLinkProps = {
to: {
pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
pathname: disableClickNavigation ? pathname : navigateUrl,
search: disableClickNavigation ? search : navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
},
onClick: handleNavLinkClick,
onAuxClick: handleNavLinkClick,
@ -301,12 +329,24 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
onClick(e, claim, indexInContainer);
}
if (claim && !pending && !disableNavigation) {
if (claim && !pending && !disableNavigation && !disableClickNavigation) {
history.push({
pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
});
}
if (playItemsOnClick && claim) {
doUriInitiatePlay(
{
uri: claim?.canonical_url || uri,
collection: { collectionId },
source: collectionId === 'queue' ? collectionId : undefined,
},
true,
disableClickNavigation
);
}
}
function removeFromHistory(e, uri) {
@ -323,7 +363,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
// **************************************************************************
// **************************************************************************
if ((shouldHide && !showNullPlaceholder) || (isLivestream && !ENABLE_NO_SOURCE_CLAIMS)) {
if (!playlistPreviewItem && ((shouldHide && !showNullPlaceholder) || (isLivestream && !ENABLE_NO_SOURCE_CLAIMS))) {
return null;
}
@ -332,7 +372,14 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
}
if (placeholder === 'loading' || (uri && !claim && isResolvingUri)) {
return <ClaimPreviewLoading isChannel={isChannelUri} type={type} />;
return (
<ClaimPreviewLoading
isChannel={isChannelUri}
type={type}
WrapperElement={WrapperElement}
xsmall={smallThumbnail}
/>
);
}
if (claim && showNullPlaceholder && shouldHide && nsfw && obscureNsfw) {
@ -345,8 +392,16 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
);
}
if (claim && showNullPlaceholder && shouldHide) {
return <ClaimPreviewHidden message={__('This content is hidden')} isChannel={isChannelUri} type={type} />;
if ((claim && showNullPlaceholder && shouldHide) || (!claim && playlistPreviewItem)) {
return (
<ClaimPreviewHidden
message={!claim && playlistPreviewItem ? __('Deleted content') : __('This content is hidden')}
isChannel={isChannelUri}
type={type}
uri={uri}
collectionId={!claim && playlistPreviewItem && collectionId ? collectionId : undefined}
/>
);
}
if (!claim && (showNullPlaceholder || empty)) {
@ -394,6 +449,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
>
<>
{!hideRepostLabel && <ClaimRepostAuthor uri={uri} />}
<div
className={classnames('claim-preview', {
'claim-preview--small': type === 'small' || type === 'tooltip',
@ -403,12 +459,23 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
'claim-preview--channel': isChannelUri,
'claim-preview--visited': !isChannelUri && !claimIsMine && hasVisitedUri,
'claim-preview--pending': pending,
'claim-preview--collection-mine': isMyCollection && showEdit,
'claim-preview--collection-editing': isMyCollection && showEdit,
'swipe-list__item': swipeLayout,
})}
>
{showIndexes && (
<span className="card__subtitle card__subtitle--small-no-margin claim-preview__list-index">
{indexInContainer + 1}
</span>
)}
{isMyCollection && showEdit && (
<CollectionEditButtons uri={uri} collectionId={listId} dragHandleProps={dragHandleProps} />
<CollectionEditButtons
uri={uri}
collectionId={listId}
dragHandleProps={dragHandleProps}
doDisablePlayerDrag={doDisablePlayerDrag}
/>
)}
{isChannelUri && claim ? (
@ -424,22 +491,24 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
<>
{!pending ? (
<NavLink aria-hidden tabIndex={-1} {...navLinkProps}>
<FileThumbnail thumbnail={thumbnailUrl}>
<div className="claim-preview__hover-actions">
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />}
</div>
{/* @if TARGET='app' */}
<div className="claim-preview__hover-actions">
{claim && !isCollection && (
<FileDownloadLink focusable={false} uri={canonicalUrl} hideOpenButton hideDownloadStatus />
)}
</div>
{/* @endif */}
{(!isLivestream || isLivestreamActive) && (
<div className="claim-preview__file-property-overlay">
<PreviewOverlayProperties uri={uri} small={type === 'small'} properties={liveProperty} />
<FileThumbnail thumbnail={thumbnailUrl} small={smallThumbnail}>
{isPlayable && !smallThumbnail && (
<div className="claim-preview__hover-actions-grid">
<FileWatchLaterLink focusable={false} uri={repostedContentUri} />
<ButtonAddToQueue focusable={false} uri={repostedContentUri} />
</div>
)}
{(!isLivestream || isLivestreamActive) && (
<div className="claim-preview__file-property-overlay">
<PreviewOverlayProperties
uri={uri}
small={type === 'small'}
xsmall={smallThumbnail}
properties={liveProperty}
/>
</div>
)}
{isCollection && <CollectionPreviewOverlay collectionId={listId} />}
<ClaimPreviewProgress uri={uri} />
</FileThumbnail>
</NavLink>

View file

@ -76,10 +76,10 @@ function ClaimPreviewSubtitle(props: Props) {
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? (
<LivestreamDateTime uri={uri} />
) : (
<>
<span className="claim-extra-info">
<FileViewCountInline uri={uri} isLivestream={isLivestream} />
<DateTime timeAgo uri={uri} />
</>
</span>
))}
</>
)}

View file

@ -6,7 +6,9 @@ import {
selectDateForUri,
selectGeoRestrictionForUri,
selectClaimIsMine,
selectCanonicalUrlForUri,
} from 'redux/selectors/claims';
import { selectUrlsForCollectionId } from 'redux/selectors/collections';
import { doFileGet } from 'redux/actions/file';
import { doResolveUri } from 'redux/actions/claims';
import { selectViewCountForUri, selectBanStateForUri } from 'lbryinc';
@ -21,6 +23,10 @@ const select = (state, props) => {
const media = claim && claim.value && (claim.value.video || claim.value.audio);
const mediaDuration = media && media.duration && formatMediaDuration(media.duration, { screenReader: true });
const isLivestream = isStreamPlaceholderClaim(claim);
const isCollection = claim && claim.value_type === 'collection';
const collectionClaimId = isCollection && claim && claim.claim_id;
const collectionUrls = collectionClaimId && selectUrlsForCollectionId(state, collectionClaimId);
const collectionFirstItem = collectionUrls && collectionUrls[0];
return {
claim,
@ -38,6 +44,7 @@ const select = (state, props) => {
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
viewCount: selectViewCountForUri(state, props.uri),
collectionFirstUrl: collectionFirstItem && selectCanonicalUrlForUri(state, collectionFirstItem),
};
};

View file

@ -17,9 +17,9 @@ import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import { formatClaimPreviewTitle } from 'util/formatAriaLabel';
import { parseURI } from 'util/lbryURI';
import PreviewOverlayProperties from 'component/previewOverlayProperties';
import FileDownloadLink from 'component/fileDownloadLink';
import FileHideRecommendation from 'component/fileHideRecommendation';
import FileWatchLaterLink from 'component/fileWatchLaterLink';
import ButtonAddToQueue from 'component/buttonAddToQueue';
import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
@ -59,6 +59,7 @@ type Props = {
swipeLayout: boolean,
onHidden?: (string) => void,
pulse?: boolean,
collectionFirstUrl: ?string,
};
// preview image cards used in related video functionality, channel overview page and homepage
@ -94,6 +95,7 @@ function ClaimPreviewTile(props: Props) {
swipeLayout = false,
onHidden,
pulse,
collectionFirstUrl,
} = props;
const isRepost = claim && claim.repost_channel_url;
const isCollection = claim && claim.value_type === 'collection';
@ -111,7 +113,7 @@ function ClaimPreviewTile(props: Props) {
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 canonicalUrl = claim && ((isCollection && collectionFirstUrl) || claim.canonical_url);
const repostedContentUri = claim && (claim.reposted_claim ? claim.reposted_claim.permanent_url : claim.permanent_url);
const listId = collectionId || collectionClaimId;
const navigateUrl =
@ -254,32 +256,30 @@ function ClaimPreviewTile(props: Props) {
<FileThumbnail thumbnail={thumbnailUrl} allowGifs tileLayout>
{!isChannel && (
<React.Fragment>
<div className="claim-preview__hover-actions">
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />}
</div>
{fypId && (
<div className="claim-preview__hover-actions">
{isStream && <FileHideRecommendation focusable={false} uri={repostedContentUri} />}
{((fypId && isStream) || isPlayable) && (
<div className="claim-preview__hover-actions-grid">
{fypId && isStream && (
<div className="claim-preview__hover-actions">
<FileHideRecommendation focusable={false} uri={repostedContentUri} />
</div>
)}
{isPlayable && (
<>
<FileWatchLaterLink focusable={false} uri={repostedContentUri} />
<ButtonAddToQueue focusable={false} uri={repostedContentUri} />
</>
)}
</div>
)}
{/* @if TARGET='app' */}
<div className="claim-preview__hover-actions">
{isStream && <FileDownloadLink focusable={false} uri={canonicalUrl} hideOpenButton />}
</div>
{/* @endif */}
<div className="claim-preview__file-property-overlay">
<PreviewOverlayProperties uri={uri} properties={liveProperty || properties} />
</div>
<ClaimPreviewProgress uri={uri} />
</React.Fragment>
)}
{isCollection && (
<React.Fragment>
<div className="claim-preview__collection-wrapper">
<CollectionPreviewOverlay collectionId={listId} uri={uri} />
</div>
</React.Fragment>
)}
{isCollection && <CollectionPreviewOverlay collectionId={listId} />}
</FileThumbnail>
</NavLink>
<div className="claim-tile__header">

View file

@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import { selectClaimRepostedAmountForUri } from 'redux/selectors/claims';
import ClaimRepostButton from './view';
const select = (state, props) => {
const { uri } = props;
return {
repostedAmount: selectClaimRepostedAmountForUri(state, uri),
};
};
const perform = {
doOpenModal,
};
export default connect(select, perform)(ClaimRepostButton);

View file

@ -0,0 +1,28 @@
// @flow
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import FileActionButton from 'component/common/file-action-button';
type Props = {
uri: string,
// redux
repostedAmount: number,
doOpenModal: (id: string, {}) => void,
};
function ClaimRepostButton(props: Props) {
const { uri, repostedAmount, doOpenModal } = props;
return (
<FileActionButton
title={__('Repost this content')}
label={repostedAmount > 1 ? __(`%repost_total% Reposts`, { repost_total: repostedAmount }) : __('Repost')}
icon={ICONS.REPOST}
requiresChannel
onClick={() => doOpenModal(MODALS.REPOST, { uri })}
/>
);
}
export default ClaimRepostButton;

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import ClaimShareButton from './view';
const perform = {
doOpenModal,
};
export default connect(null, perform)(ClaimShareButton);

View file

@ -0,0 +1,30 @@
// @flow
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import FileActionButton from 'component/common/file-action-button';
type Props = {
uri: string,
fileAction?: boolean,
webShareable: boolean,
collectionId?: string,
// redux
doOpenModal: (id: string, {}) => void,
};
function ClaimShareButton(props: Props) {
const { uri, fileAction, collectionId, webShareable, doOpenModal } = props;
return (
<FileActionButton
title={__('Share this content')}
label={__('Share')}
icon={ICONS.SHARE}
onClick={() => doOpenModal(MODALS.SOCIAL_SHARE, { uri, webShareable, collectionId })}
noStyle={!fileAction}
/>
);
}
export default ClaimShareButton;

View file

@ -2,9 +2,7 @@
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import Button from 'component/button';
import Tooltip from 'component/common/tooltip';
import FileActionButton from 'component/common/file-action-button';
type Props = {
uri: string,
@ -19,6 +17,8 @@ type Props = {
export default function ClaimSupportButton(props: Props) {
const { uri, fileAction, isRepost, disableSupport, doOpenModal, preferredCurrency } = props;
if (disableSupport) return null;
const currencyToUse = preferredCurrency;
const iconToUse = {
@ -32,17 +32,14 @@ export default function ClaimSupportButton(props: Props) {
},
};
return disableSupport ? null : (
<Tooltip title={__('Support this claim')} arrow={false}>
<Button
button={!fileAction ? 'alt' : undefined}
className={classnames('support-claim-button', { 'button--file-action': fileAction })}
icon={iconToUse[currencyToUse].icon}
iconSize={iconToUse[currencyToUse].iconSize}
label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')}
requiresAuth
onClick={() => doOpenModal(MODALS.SEND_TIP, { uri, isSupport: true })}
/>
</Tooltip>
return (
<FileActionButton
title={__('Support this content')}
label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')}
icon={iconToUse[currencyToUse].icon}
iconSize={iconToUse[currencyToUse].iconSize}
onClick={() => doOpenModal(MODALS.SEND_TIP, { uri, isSupport: true })}
noStyle={!fileAction}
/>
);
}

View file

@ -16,7 +16,7 @@ function ClaimType(props: Props) {
const size = small ? COL.ICON_SIZE : undefined;
if (claimType === 'collection') {
return <Icon size={size} icon={ICONS.STACK} />;
return <Icon size={size} icon={ICONS.PLAYLIST} />;
} else if (claimType === 'channel') {
return <Icon size={size} icon={ICONS.CHANNEL} />;
} else if (claimType === 'repost') {

View file

@ -1,45 +0,0 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, makeSelectClaimIsPending } from 'redux/selectors/claims';
import { makeSelectCollectionIsMine, makeSelectEditedCollectionForId } from 'redux/selectors/collections';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doToggleShuffleList, doToggleLoopList } from 'redux/actions/content';
import CollectionActions from './view';
const select = (state, props) => {
let firstItem;
const collectionUrls = props.collectionUrls;
if (collectionUrls) {
// this will help play the first valid claim in a list
// in case the first urls have been deleted
collectionUrls.map((url) => {
const claim = makeSelectClaimForUri(url)(state);
if (firstItem === undefined && claim) {
firstItem = claim.permanent_url;
}
});
}
const collectionId = props.collectionId;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
return {
claim: makeSelectClaimForUri(props.uri)(state),
claimIsPending: makeSelectClaimIsPending(props.uri)(state),
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(collectionId)(state)),
firstItem,
playNextUri,
};
};
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToggleShuffleList: (collectionId, shuffle) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, shuffle, true));
},
});
export default connect(select, perform)(CollectionActions);

View file

@ -1,200 +0,0 @@
// @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';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
type Props = {
uri: string,
claim: StreamClaim,
openModal: (id: string, {}) => void,
claimIsPending: boolean,
isMyCollection: boolean,
collectionId: string,
showInfo: boolean,
setShowInfo: (boolean) => void,
showEdit: boolean,
setShowEdit: (boolean) => void,
collectionHasEdits: boolean,
isBuiltin: boolean,
doToggleShuffleList: (string, boolean) => void,
playNextUri: string,
firstItem: string,
};
function CollectionActions(props: Props) {
const {
uri,
openModal,
claim,
claimIsPending,
isMyCollection,
collectionId,
showInfo,
setShowInfo,
collectionHasEdits,
isBuiltin,
doToggleShuffleList,
playNextUri,
firstItem,
showEdit,
setShowEdit,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory();
const isMobile = useIsMobile();
const claimId = claim && claim.claim_id;
const webShareable = true; // collections have cost?
const doPlay = React.useCallback(
(playUri) => {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: generateListSearchUrlParams(collectionId),
state: { forceAutoplay: true },
});
},
[collectionId, push]
);
React.useEffect(() => {
if (playNextUri && doShuffle) {
setDoShuffle(false);
doPlay(playNextUri);
}
}, [doPlay, doShuffle, playNextUri]);
const lhsSection = (
<>
<Button
className="button--file-action"
icon={ICONS.PLAY}
label={__('Play')}
title={__('Play')}
onClick={() => {
doToggleShuffleList(collectionId, false);
doPlay(firstItem);
}}
/>
<Button
className="button--file-action"
icon={ICONS.SHUFFLE}
label={__('Shuffle Play')}
title={__('Shuffle Play')}
onClick={() => {
doToggleShuffleList(collectionId, true);
setDoShuffle(true);
}}
/>
{!isBuiltin && (
<>
{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 = (
<>
{!isBuiltin &&
(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}
/>
<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}
/>
</>
) : (
<Button
title={__('Report content')}
className="button--file-action"
icon={ICONS.REPORT}
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
/>
))}
</>
);
const infoButtons = (
<div className="section">
{uri && (
<Button
title={__('Info')}
className={classnames('button-toggle', {
'button-toggle--active': showInfo,
})}
icon={ICONS.MORE}
onClick={() => setShowInfo(!showInfo)}
/>
)}
{isMyCollection && (
<Button
title={__('Edit')}
className={classnames('button-toggle', { 'button-toggle--active': showEdit })}
icon={ICONS.EDIT}
onClick={() => setShowEdit(!showEdit)}
/>
)}
</div>
);
if (isMobile) {
return (
<div className="media__actions stretch">
{lhsSection}
{rhsSection}
{infoButtons}
</div>
);
} else {
return (
<div className="media__subtitle--between">
<div className="section__actions">
{lhsSection}
{rhsSection}
</div>
{infoButtons}
</div>
);
}
}
export default CollectionActions;

View file

@ -1,38 +0,0 @@
import { connect } from 'react-redux';
import CollectionContent from './view';
import { selectClaimForUri } from 'redux/selectors/claims';
import {
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
makeSelectCollectionForId,
makeSelectCollectionIsMine,
} from 'redux/selectors/collections';
import { selectPlayingUri, selectListLoop, selectListShuffle } from 'redux/selectors/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
import { doCollectionEdit } from 'redux/actions/collections';
const select = (state, props) => {
const { uri: playingUri } = selectPlayingUri(state);
const { permanent_url: url } = selectClaimForUri(state, playingUri) || {};
const loopList = selectListLoop(state);
const loop = loopList && loopList.collectionId === props.id && loopList.loop;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === props.id && shuffleList.newUrls;
return {
url,
collection: makeSelectCollectionForId(props.id)(state),
collectionUrls: makeSelectUrlsForCollectionId(props.id)(state),
collectionName: makeSelectNameForCollectionId(props.id)(state),
isMyCollection: makeSelectCollectionIsMine(props.id)(state),
loop,
shuffle,
};
};
export default connect(select, {
doToggleLoopList,
doToggleShuffleList,
doCollectionEdit,
})(CollectionContent);

View file

@ -1,133 +0,0 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import ClaimList from 'component/claimList';
import Card from 'component/common/card';
import Button from 'component/button';
import * as PAGES from 'constants/pages';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
// prettier-ignore
const Lazy = {
// $FlowFixMe
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
// $FlowFixMe
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
};
type Props = {
id: string,
url: string,
isMyCollection: boolean,
collectionUrls: Array<Claim>,
collectionName: string,
collection: any,
loop: boolean,
shuffle: boolean,
doToggleLoopList: (string, boolean) => void,
doToggleShuffleList: (string, string, boolean) => void,
createUnpublishedCollection: (string, Array<any>, ?string) => void,
doCollectionEdit: (string, CollectionEditParams) => void,
};
export default function CollectionContent(props: Props) {
const {
isMyCollection,
collectionUrls,
collectionName,
id,
url,
loop,
shuffle,
doToggleLoopList,
doToggleShuffleList,
doCollectionEdit,
} = props;
const [showEdit, setShowEdit] = React.useState(false);
function handleOnDragEnd(result) {
const { source, destination } = result;
if (!destination) return;
const { index: from } = source;
const { index: to } = destination;
doCollectionEdit(id, { order: { from, to } });
}
return (
<Card
isBodyList
className="file-page__playlist-collection"
title={
<>
<a href={`/$/${PAGES.LIST}/${id}`}>
<span className="file-page__playlist-collection__row">
<Icon
icon={
(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
ICONS.STACK
}
className="icon--margin-right"
/>
{collectionName}
</span>
</a>
<span className="file-page__playlist-collection__row">
<Button
button="alt"
title={__('Loop')}
icon={ICONS.REPEAT}
iconColor={loop && 'blue'}
className="button--file-action"
onClick={() => doToggleLoopList(id, !loop)}
/>
<Button
button="alt"
title={__('Shuffle')}
icon={ICONS.SHUFFLE}
iconColor={shuffle && 'blue'}
className="button--file-action"
onClick={() => doToggleShuffleList(url, id, !shuffle)}
/>
</span>
</>
}
titleActions={
isMyCollection && (
<Button
title={__('Edit')}
className={classnames('button-toggle', { 'button-toggle--active': showEdit })}
icon={ICONS.EDIT}
onClick={() => setShowEdit(!showEdit)}
/>
)
}
body={
<React.Suspense fallback={null}>
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
<Lazy.Droppable droppableId="list__ordering">
{(DroppableProvided) => (
<ClaimList
isCardBody
type="small"
activeUri={url}
uris={collectionUrls}
collectionId={id}
empty={__('List is Empty')}
showEdit={showEdit}
droppableProvided={DroppableProvided}
/>
)}
</Lazy.Droppable>
</Lazy.DragDropContext>
</React.Suspense>
}
/>
);
}

View file

@ -1,54 +0,0 @@
import { connect } from 'react-redux';
import {
selectTitleForUri,
selectThumbnailForUri,
makeSelectMetadataItemForUri,
makeSelectAmountForUri,
makeSelectClaimForUri,
selectUpdateCollectionError,
selectUpdatingCollection,
selectCreateCollectionError,
selectCreatingCollection,
} from 'redux/selectors/claims';
import {
makeSelectCollectionForId,
makeSelectUrlsForCollectionId,
makeSelectClaimIdsForCollectionId,
} from 'redux/selectors/collections';
import { doCollectionPublish, doCollectionPublishUpdate } from 'redux/actions/claims';
import { selectBalance } from 'redux/selectors/wallet';
import * as ACTIONS from 'constants/action_types';
import CollectionForm from './view';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doCollectionEdit } from 'redux/actions/collections';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
title: selectTitleForUri(state, props.uri),
thumbnailUrl: selectThumbnailForUri(state, props.uri),
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, ownProps) => ({
publishCollectionUpdate: (params) => dispatch(doCollectionPublishUpdate(params)),
publishCollection: (params, collectionId) => dispatch(doCollectionPublish(params, collectionId)),
clearCollectionErrors: () => dispatch({ type: ACTIONS.CLEAR_COLLECTION_ERRORS }),
doCollectionEdit: (params) => dispatch(doCollectionEdit(ownProps.collectionId, params)),
});
export default connect(select, perform)(CollectionForm);

View file

@ -1,511 +0,0 @@
// @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 SelectThumbnail from 'component/selectThumbnail';
import { useHistory } from 'react-router-dom';
import { isNameValid, regexInvalidURI } from 'util/lbryURI';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
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';
// prettier-ignore
const Lazy = {
// $FlowFixMe
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
// $FlowFixMe
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
};
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>,
updatingCollection: boolean,
updateError: string,
createError: string,
creatingCollection: boolean,
publishCollectionUpdate: (CollectionUpdateParams) => Promise<any>,
publishCollection: (CollectionPublishParams, string) => Promise<any>,
clearCollectionErrors: () => void,
onDone: (string) => void,
doCollectionEdit: (CollectionEditParams) => void,
};
function CollectionForm(props: Props) {
const {
uri, // collection uri
claim,
balance,
// publish params
amount,
title,
description,
thumbnailUrl,
tags,
locations,
languages = [],
// rest
updateError,
updatingCollection,
creatingCollection,
createError,
disabled,
activeChannelClaim,
incognito,
collectionId,
collection,
collectionUrls,
collectionClaimIds,
publishCollectionUpdate,
publishCollection,
clearCollectionErrors,
onDone,
doCollectionEdit,
} = 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 collectionChannel = claim && claim.signing_channel ? claim.signing_channel.claim_id : undefined;
const hasClaim = !!claim;
const [nameError, setNameError] = React.useState(undefined);
const [bidError, setBidError] = React.useState('');
const [thumbStatus, setThumbStatus] = React.useState('');
const [thumbError, setThumbError] = React.useState('');
const [params, setParams]: [any, (any) => void] = React.useState({});
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 hasClaims = params.claims && params.claims.length;
const collectionClaimIdsString = JSON.stringify(collectionClaimIds);
const itemError = !hasClaims ? __('Cannot publish empty list') : '';
const thumbnailError =
(thumbError && thumbStatus !== THUMBNAIL_STATUSES.COMPLETE && __('Invalid thumbnail')) ||
(thumbStatus === THUMBNAIL_STATUSES.IN_PROGRESS && __('Please wait for thumbnail to finish uploading'));
const submitError = nameError || bidError || itemError || updateError || createError || thumbnailError;
function parseName(newName) {
let INVALID_URI_CHARS = new RegExp(regexInvalidURI, 'gu');
return newName.replace(INVALID_URI_CHARS, '-');
}
function setParam(paramObj) {
setParams({ ...params, ...paramObj });
}
function updateParams(paramsObj) {
setParams({ ...params, ...paramsObj });
}
// TODO remove this or better decide whether app should delete languages[2+]
// This was added because a previous update setting was duplicating language codes
function dedupeLanguages(languages) {
if (languages.length <= 1) {
return languages;
} else if (languages.length === 2) {
if (languages[0] !== languages[1]) {
return languages;
} else {
return [languages[0]];
}
} else if (languages.length > 2) {
const newLangs = [];
languages.forEach((l) => {
if (!newLangs.includes(l)) {
newLangs.push(l);
}
});
return newLangs;
}
}
function handleUpdateThumbnail(update: { [string]: string }) {
if (update.thumbnail_url) {
setParam(update);
} else if (update.thumbnail_status) {
setThumbStatus(update.thumbnail_status);
} else {
setThumbError(update.thumbnail_error);
}
}
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,
name: parseName(collectionName),
description,
title: claim ? title : collectionName,
bid: String(amount || 0.001),
languages: languages ? dedupeLanguages(languages) : [],
locations: locations || [],
tags: tags
? tags.map((tag) => {
return { name: tag };
})
: [],
claim_id: claim ? claim.claim_id : undefined,
channel_id: claim ? collectionChannel : activeChannelId || undefined,
claims: collectionClaimIds,
};
return collectionParams;
}
function handleOnDragEnd(result) {
const { source, destination } = result;
if (!destination) return;
const { index: from } = source;
const { index: to } = destination;
doCollectionEdit({ order: { from, to } });
}
function handleLanguageChange(index, code) {
let langs = [...languageParam];
if (index === 0) {
if (code === LANG_NONE) {
// clear all
langs = [];
} else {
langs[0] = code;
if (langs[0] === langs[1]) {
langs.length = 1;
}
}
} else {
if (code === LANG_NONE || code === langs[0]) {
langs.splice(1, 1);
} else {
langs[index] = code;
}
}
setParams({ ...params, languages: langs });
}
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(() => {
const collectionClaimIds = JSON.parse(collectionClaimIdsString);
setParams({ ...params, claims: collectionClaimIds });
clearCollectionErrors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionClaimIdsString, setParams]);
React.useEffect(() => {
let nameError;
if (!name && name !== undefined) {
nameError = __('A name is required for your url');
} else if (!isNameValid(name)) {
nameError = INVALID_NAME_ERROR;
}
setNameError(nameError);
}, [name]);
// every time activechannel or incognito changes, set it.
React.useEffect(() => {
if (activeChannelId) {
setParam({ channel_id: activeChannelId });
} else if (incognito) {
setParam({ channel_id: undefined });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeChannelId, incognito]);
// setup initial params after we're sure if it's published or not
React.useEffect(() => {
if (!uri || (uri && hasClaim)) {
updateParams(getCollectionParams());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uri, hasClaim]);
return (
<>
<div className={classnames('main--contained publishList-wrapper', { 'card--disabled': disabled })}>
<Tabs>
<TabList className="tabs__list--collection-edit-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} autoSet channelToSet={collectionChannel} />
<Card
body={
<>
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="channel_name">{__('Name')}</label>
<div className="form-field__prefix">{prefix}</div>
</fieldset-section>
<FormField
autoFocus={isNewCollection}
type="text"
name="channel_name"
placeholder={__('MyAwesomeList')}
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 List')}
value={params.title}
onChange={(e) => setParams({ ...params, title: e.target.value })}
/>
<fieldset-section>
<SelectThumbnail
thumbnailParam={params.thumbnail_url}
thumbnailParamError={thumbError}
thumbnailParamStatus={thumbStatus}
updateThumbnailParams={handleUpdateThumbnail}
/>
</fieldset-section>
<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>
<React.Suspense fallback={null}>
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
<Lazy.Droppable droppableId="list__ordering">
{(DroppableProvided) => (
<ClaimList
uris={collectionUrls}
collectionId={collectionId}
empty={__('This list has no items.')}
showEdit
droppableProvided={DroppableProvided}
/>
)}
</Lazy.Droppable>
</Lazy.DragDropContext>
</React.Suspense>
</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 || thumbnailError || !hasClaims
}
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

@ -1,14 +1,14 @@
import { connect } from 'react-redux';
import { doCollectionEdit } from 'redux/actions/collections';
import { makeSelectIndexForUrlInCollection, makeSelectUrlsForCollectionId } from 'redux/selectors/collections';
import { selectIndexForUrlInCollection, selectUrlsForCollectionId } from 'redux/selectors/collections';
import CollectionButtons from './view';
const select = (state, props) => {
const { uri, collectionId } = props;
return {
collectionIndex: makeSelectIndexForUrlInCollection(uri, collectionId, true)(state),
collectionUris: makeSelectUrlsForCollectionId(collectionId)(state),
collectionIndex: selectIndexForUrlInCollection(state, uri, collectionId, true),
collectionUris: selectUrlsForCollectionId(state, collectionId),
};
};

View file

@ -3,6 +3,7 @@ import * as ICONS from 'constants/icons';
import Button from 'component/button';
import Icon from 'component/common/icon';
import React from 'react';
import classnames from 'classnames';
type Props = {
collectionIndex?: number,
@ -10,26 +11,24 @@ type Props = {
dragHandleProps?: any,
uri: string,
editCollection: (CollectionEditParams) => void,
doDisablePlayerDrag?: (disable: boolean) => void,
};
export default function CollectionButtons(props: Props) {
const { collectionIndex: foundIndex, collectionUris, dragHandleProps, uri, editCollection } = props;
const {
collectionIndex: foundIndex,
collectionUris,
dragHandleProps,
uri,
editCollection,
doDisablePlayerDrag,
} = props;
const [confirmDelete, setConfirmDelete] = React.useState(false);
const lastCollectionIndex = collectionUris ? collectionUris.length - 1 : 0;
const collectionIndex = Number(foundIndex);
const orderButton = (className: string, title: string, icon: string, disabled: boolean, handleClick?: () => void) => (
<Button
className={`button-collection-manage ${className}`}
icon={icon}
title={title}
disabled={disabled}
onClick={() => handleClick && handleClick()}
/>
);
return (
<div
className="collection-preview__edit-buttons"
@ -39,29 +38,45 @@ export default function CollectionButtons(props: Props) {
}}
>
<div className="collection-preview__edit-group" {...dragHandleProps}>
<div className="button-collection-manage button-collection-drag top-left bottom-left">
<div
className="button-collection-manage button-collection-drag top-left bottom-left"
onMouseEnter={doDisablePlayerDrag ? () => doDisablePlayerDrag(true) : undefined}
onMouseLeave={doDisablePlayerDrag ? () => doDisablePlayerDrag(false) : undefined}
>
<Icon icon={ICONS.DRAG} title={__('Drag')} size={20} />
</div>
</div>
<div className="collection-preview__edit-group">
{orderButton('', __('Move Top'), ICONS.UP_TOP, collectionIndex === 0, () =>
editCollection({ order: { from: collectionIndex, to: 0 } })
)}
<OrderButton
title={__('Move Top')}
icon={ICONS.UP_TOP}
disabled={collectionIndex === 0}
onClick={() => editCollection({ order: { from: collectionIndex, to: 0 } })}
/>
{orderButton('', __('Move Bottom'), ICONS.DOWN_BOTTOM, collectionIndex === lastCollectionIndex, () =>
editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } })
)}
<OrderButton
title={__('Move Bottom')}
icon={ICONS.DOWN_BOTTOM}
disabled={collectionIndex === lastCollectionIndex}
onClick={() => editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } })}
/>
</div>
<div className="collection-preview__edit-group">
{orderButton('', __('Move Up'), ICONS.UP, collectionIndex === 0, () =>
editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } })
)}
<OrderButton
title={__('Move Up')}
icon={ICONS.UP}
disabled={collectionIndex === 0}
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } })}
/>
{orderButton('', __('Move Down'), ICONS.DOWN, collectionIndex === lastCollectionIndex, () =>
editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } })
)}
<OrderButton
title={__('Move Down')}
icon={ICONS.DOWN}
disabled={collectionIndex === lastCollectionIndex}
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } })}
/>
</div>
{!confirmDelete ? (
@ -82,11 +97,24 @@ export default function CollectionButtons(props: Props) {
onClick={() => setConfirmDelete(false)}
/>
{orderButton('button-collection-delete-confirm bottom-right', __('Remove'), ICONS.DELETE, false, () =>
editCollection({ uris: [uri], remove: true })
)}
<OrderButton
className="button-collection-delete-confirm bottom-right"
title={__('Remove')}
icon={ICONS.DELETE}
onClick={() => editCollection({ uris: [uri], remove: true })}
/>
</div>
)}
</div>
);
}
type ButtonProps = {
className?: string,
};
const OrderButton = (props: ButtonProps) => {
const { className, ...buttonProps } = props;
return <Button className={classnames('button-collection-manage', className)} {...buttonProps} />;
};

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { selectChannelForUri } from 'redux/selectors/claims';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import CollectionGeneralTab from './view';
const select = (state, props) => {
const { uri, isPrivateEdit } = props;
return {
collectionChannel: !isPrivateEdit && selectChannelForUri(state, uri),
activeChannelClaim: !isPrivateEdit && selectActiveChannelClaim(state),
incognito: selectIncognito(state),
};
};
export default connect(select)(CollectionGeneralTab);

View file

@ -0,0 +1,170 @@
// @flow
import { DOMAIN } from 'config';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import React from 'react';
import ChannelSelector from 'component/channelSelector';
import Card from 'component/common/card';
import SelectThumbnail from 'component/selectThumbnail';
import { FormField } from 'component/common/form';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import Spinner from 'component/spinner';
type Props = {
uri: string,
params: any,
nameError: any,
isPrivateEdit?: boolean,
incognito: boolean,
setThumbnailError: (error: ?string) => void,
updateParams: (obj: any) => void,
setLoading: (loading: boolean) => void,
// -- redux --
collectionChannel: ?ChannelClaim,
activeChannelClaim: ?ChannelClaim,
};
function CollectionGeneralTab(props: Props) {
const {
uri,
params,
nameError,
isPrivateEdit,
incognito,
setThumbnailError,
updateParams,
setLoading,
// -- redux --
collectionChannel,
activeChannelClaim,
} = props;
const { name, description } = params;
const thumbnailUrl = params.thumbnail_url || params.thumbnail?.url;
const title = params.title || name;
const [thumbStatus, setThumbStatus] = React.useState();
const [thumbError, setThumbError] = React.useState();
const { name: activeChannelName } = activeChannelClaim || {};
const isNewCollection = !uri;
function handleUpdateThumbnail(update: { [string]: string }) {
const { thumbnail_url: url, thumbnail_status: status, thumbnail_error: error } = update;
if (url?.length >= 0) {
const newParams = url.length === 0 ? { thumbnail_url: undefined } : update;
updateParams(isPrivateEdit ? { thumbnail: { url: newParams.thumbnail_url } } : newParams);
setThumbStatus(undefined);
setThumbError(undefined);
} else {
if (status) {
setThumbStatus(status);
} else {
setThumbError(error);
}
}
}
React.useEffect(() => {
const thumbnailError =
thumbError && thumbStatus !== THUMBNAIL_STATUSES.COMPLETE
? __('Invalid thumbnail')
: thumbStatus === THUMBNAIL_STATUSES.IN_PROGRESS
? __('Please wait for thumbnail to finish uploading')
: undefined;
setThumbnailError(thumbnailError);
}, [setThumbnailError, thumbError, thumbStatus]);
React.useEffect(() => {
if (setLoading) setLoading(!activeChannelClaim);
}, [activeChannelClaim, setLoading]);
if (!activeChannelClaim && !isPrivateEdit) {
return (
<div className="main--empty">
<Spinner />
</div>
);
}
return (
<div className="card-stack">
{!isPrivateEdit && (
<ChannelSelector
autoSet
channelToSet={collectionChannel}
onChannelSelect={(id) => updateParams({ channel_id: id })}
/>
)}
<Card
body={
<>
{!isPrivateEdit && (
<fieldset-group class="fieldset-group--smushed fieldset-group--disabled-prefix">
<fieldset-section>
<label htmlFor="collection_name">{__('Name')}</label>
<div className="form-field__prefix">
{incognito ? `${DOMAIN}/` : `${DOMAIN}/${activeChannelName}/`}
</div>
</fieldset-section>
<FormField
autoFocus={isNewCollection}
type="text"
name="collection_name"
placeholder={__('MyAwesomeList')}
value={name || ''}
error={nameError}
disabled={!isNewCollection}
onChange={(e) => updateParams({ name: e.target.value || '' })}
/>
</fieldset-group>
)}
{!isPrivateEdit && (
<span className="form-field__help">
{isNewCollection
? __("This won't be able to be changed in the future.")
: __('This field cannot be changed.')}
</span>
)}
<FormField
type="text"
name="collection_title"
label={__('Title')}
placeholder={__('My Awesome Playlist')}
value={title || ''}
onChange={(e) =>
updateParams(isPrivateEdit ? { name: e.target.value || '' } : { title: e.target.value || '' })
}
/>
<fieldset-section>
<SelectThumbnail
thumbnailParam={thumbnailUrl}
thumbnailParamError={thumbError}
thumbnailParamStatus={thumbStatus}
updateThumbnailParams={handleUpdateThumbnail}
optional
/>
</fieldset-section>
<FormField
type="markdown"
name="collection_description"
label={__('Description')}
value={description || ''}
onChange={(text) => updateParams({ description: text || '' })}
textAreaMaxLength={FF_MAX_CHARS_IN_DESCRIPTION}
/>
</>
}
/>
</div>
);
}
export default CollectionGeneralTab;

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { doCollectionEdit, doFetchItemsInCollection } from 'redux/actions/collections';
import {
selectUrlsForCollectionId,
selectIsResolvingCollectionForId,
selectIsCollectionPrivateForId,
selectCollectionForId,
} from 'redux/selectors/collections';
import CollectionItemsList from './view';
const select = (state, props) => {
const { collectionId } = props;
return {
collectionUrls: selectUrlsForCollectionId(state, collectionId),
collection: selectCollectionForId(state, collectionId),
isResolvingCollection: selectIsResolvingCollectionForId(state, collectionId),
isPrivateCollection: selectIsCollectionPrivateForId(state, collectionId),
};
};
const perform = {
doCollectionEdit,
doFetchItemsInCollection,
};
export default connect(select, perform)(CollectionItemsList);

View file

@ -0,0 +1,86 @@
// @flow
import React from 'react';
import ClaimList from 'component/claimList';
import Spinner from 'component/spinner';
// prettier-ignore
const Lazy = {
// $FlowFixMe
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
// $FlowFixMe
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
};
type Props = {
collectionId: string,
// -- redux --
collection: Collection,
isPrivateCollection: boolean,
isResolvingCollection: boolean,
collectionUrls: Array<string>,
doCollectionEdit: (id: string, params: CollectionEditParams) => void,
doFetchItemsInCollection: ({}, ?() => void) => void,
};
const CollectionItemsList = (props: Props) => {
const {
collectionId,
collection,
isPrivateCollection,
collectionUrls,
isResolvingCollection,
doCollectionEdit,
doFetchItemsInCollection,
...claimListProps
} = props;
const [didTryResolve, setDidTryResolve] = React.useState();
const { totalItems } = collection || {};
const urlsReady = collectionUrls && (totalItems === undefined || totalItems === collectionUrls.length);
const shouldFetchItems = isPrivateCollection || (!urlsReady && collectionId && !didTryResolve && !collection);
function handleOnDragEnd(result: any) {
const { source, destination } = result;
if (!destination) return;
const { index: from } = source;
const { index: to } = destination;
doCollectionEdit(collectionId, { order: { from, to } });
}
React.useEffect(() => {
if (shouldFetchItems) {
doFetchItemsInCollection({ collectionId }, () => setDidTryResolve(true));
}
}, [collectionId, doFetchItemsInCollection, shouldFetchItems]);
return (
<React.Suspense fallback={null}>
{isResolvingCollection && (
<div className="main--empty">
<Spinner />
</div>
)}
{!isResolvingCollection && (
<Lazy.DragDropContext onDragEnd={handleOnDragEnd}>
<Lazy.Droppable droppableId="list__ordering">
{(DroppableProvided) => (
<ClaimList
collectionId={collectionId}
uris={collectionUrls}
droppableProvided={DroppableProvided}
{...claimListProps}
/>
)}
</Lazy.Droppable>
</Lazy.DragDropContext>
)}
</React.Suspense>
);
};
export default CollectionItemsList;

View file

@ -1,28 +1,32 @@
import { connect } from 'react-redux';
import { makeSelectNameForCollectionId } from 'redux/selectors/collections';
import {
selectNameForCollectionId,
selectIsCollectionBuiltInForId,
selectPublishedCollectionNotEditedForId,
selectCollectionIsEmptyForId,
} from 'redux/selectors/collections';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
import { selectListShuffleForId } from 'redux/selectors/content';
import { doToggleShuffleList } from 'redux/actions/content';
import CollectionMenuList from './view';
const select = (state, props) => {
const collectionId = props.collectionId;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
const shuffleList = selectListShuffleForId(state, collectionId);
const playNextUri = shuffleList && shuffleList.newUrls[0];
return {
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
collectionName: selectNameForCollectionId(state, collectionId),
playNextUri,
isBuiltin: selectIsCollectionBuiltInForId(state, collectionId),
publishedNotEdited: selectPublishedCollectionNotEditedForId(state, collectionId),
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
};
};
const perform = (dispatch) => ({
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToggleShuffleList: (collectionId) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
},
doToggleShuffleList: (params) => dispatch(doToggleShuffleList(params)),
});
export default connect(select, perform)(CollectionMenuList);

View file

@ -8,6 +8,7 @@ import Icon from 'component/common/icon';
import * as PAGES from 'constants/pages';
import { useHistory } from 'react-router';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import { PUBLISH_PAGE, EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
type Props = {
inline?: boolean,
@ -15,11 +16,24 @@ type Props = {
collectionName?: string,
collectionId: string,
playNextUri: string,
doToggleShuffleList: (string) => void,
doToggleShuffleList: (params: { currentUri?: string, collectionId: string, hideToast?: boolean }) => void,
isBuiltin: boolean,
publishedNotEdited: boolean,
collectionEmpty: boolean,
};
function CollectionMenuList(props: Props) {
const { inline = false, collectionId, collectionName, doOpenModal, playNextUri, doToggleShuffleList } = props;
const {
inline = false,
collectionId,
collectionName,
doOpenModal,
playNextUri,
doToggleShuffleList,
isBuiltin,
publishedNotEdited,
collectionEmpty,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory();
@ -47,45 +61,64 @@ function CollectionMenuList(props: Props) {
>
<Icon size={20} icon={ICONS.MORE_VERTICAL} />
</MenuButton>
<MenuList className="menu__list">
{collectionId && collectionName && (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.PLAYLIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
{__('Open')}
</a>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => {
doToggleShuffleList(collectionId);
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.PUBLISH} />
{__('Publish 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>
{!collectionEmpty && (
<MenuItem
className="comment__menu-option"
onSelect={() => {
doToggleShuffleList({ collectionId });
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
)}
{!isBuiltin && (
<>
{!collectionEmpty && (
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`)}
>
<div className="menu__link">
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
{publishedNotEdited ? __('Update') : __('Publish')}
</div>
</MenuItem>
)}
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.EDIT} />
{__('Edit')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => doOpenModal(MODALS.COLLECTION_DELETE, { collectionId })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete')}
</div>
</MenuItem>
</>
)}
</>
)}
</MenuList>

View file

@ -1,33 +1,21 @@
import { connect } from 'react-redux';
import { selectIsUriResolving, selectClaimIdForUri, makeSelectClaimForClaimId } from 'redux/selectors/claims';
import {
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
makeSelectPendingCollectionForId,
makeSelectCountForCollectionId,
} from 'redux/selectors/collections';
import { selectThumbnailForId } from 'redux/selectors/claims';
import { selectUrlsForCollectionId } from 'redux/selectors/collections';
import { doFetchItemsInCollection } from 'redux/actions/collections';
import CollectionPreviewOverlay from './view';
const select = (state, props) => {
const collectionId = props.collectionId || (props.uri && selectClaimIdForUri(state, props.uri));
const claim = props.collectionId && makeSelectClaimForClaimId(props.collectionId)(state);
const collectionUri = props.uri || (claim && (claim.canonical_url || claim.permanent_url)) || null;
const { collectionId } = props;
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 && selectIsUriResolving(state, collectionUri),
collectionItemUrls: selectUrlsForCollectionId(state, collectionId),
collectionThumbnail: selectThumbnailForId(state, collectionId),
};
};
const perform = (dispatch) => ({
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })), // if collection not resolved, resolve it
});
const perform = {
doFetchItemsInCollection,
};
export default connect(select, perform)(CollectionPreviewOverlay);

View file

@ -4,43 +4,42 @@ 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,
// redux
collectionItemUrls: Array<string>,
fetchCollectionItems: (string) => void,
collectionThumbnail: ?string,
doFetchItemsInCollection: (options: CollectionFetchParams) => void,
};
function CollectionPreviewOverlay(props: Props) {
const { collectionId, collectionItemUrls, fetchCollectionItems } = props;
const { collectionId, collectionItemUrls, collectionThumbnail, doFetchItemsInCollection } = props;
React.useEffect(() => {
if (!collectionItemUrls) {
fetchCollectionItems(collectionId);
doFetchItemsInCollection({ collectionId, pageSize: 3 });
}
}, [collectionId, collectionItemUrls, fetchCollectionItems]);
}, [collectionId, collectionItemUrls, doFetchItemsInCollection]);
if (collectionItemUrls && collectionItemUrls.length > 0) {
const displayed = collectionItemUrls.slice(0, 2);
return (
<div className="collection-preview__overlay-thumbs">
<div className="collection-preview__overlay-side" />
<div className="collection-preview__overlay-grid">
{displayed.map((uri) => (
<div className="collection-preview__overlay-grid-items" key={uri}>
<FileThumbnail uri={uri} />
</div>
))}
</div>
</div>
);
if (!collectionItemUrls || collectionItemUrls.length === 0) {
return null;
}
return null;
// if the playlist's thumbnail is the first item of the list, then don't show it again
// on the preview overlay (show the second and third instead)
const isThumbnailFirstItem = collectionItemUrls.length > 2 && !collectionThumbnail;
const displayedItems = isThumbnailFirstItem ? collectionItemUrls.slice(1, 3) : collectionItemUrls.slice(0, 2);
return (
<div className="claim-preview__collection-wrapper">
<ul className="ul--no-style collection-preview-overlay__grid">
{displayedItems.map((uri) => (
<li className="li--no-style collection-preview-overlay__grid-item" key={uri}>
<FileThumbnail uri={uri} />
</li>
))}
</ul>
</div>
);
}
export default withRouter(CollectionPreviewOverlay);

View file

@ -1,19 +0,0 @@
// @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

@ -1,14 +0,0 @@
// @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

@ -1,59 +0,0 @@
import { connect } from 'react-redux';
import {
selectIsUriResolving,
selectTitleForUri,
makeSelectChannelForClaimUri,
selectClaimIsNsfwForUri,
selectClaimIdForUri,
makeSelectClaimForClaimId,
} from 'redux/selectors/claims';
import {
makeSelectUrlsForCollectionId,
makeSelectNameForCollectionId,
makeSelectEditedCollectionForId,
makeSelectPendingCollectionForId,
makeSelectCountForCollectionId,
makeSelectIsResolvingCollectionForId,
} from 'redux/selectors/collections';
import { doFetchItemsInCollection, doCollectionDelete } from 'redux/actions/collections';
import { doResolveUri } from 'redux/actions/claims';
import { selectMutedChannels } from 'redux/selectors/blocked';
import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc';
import { selectShowMatureContent } from 'redux/selectors/settings';
import { getThumbnailFromClaim } from 'util/claim';
import CollectionPreviewTile from './view';
const select = (state, props) => {
const collectionId = props.collectionId || (props.uri && selectClaimIdForUri(state, 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 && selectIsUriResolving(state, collectionUri),
thumbnail: getThumbnailFromClaim(claim),
title: collectionUri && selectTitleForUri(state, collectionUri),
blackListedOutpoints: selectBlackListedOutpoints(state),
filteredOutpoints: selectFilteredOutpoints(state),
blockedChannelUris: selectMutedChannels(state),
showMature: selectShowMatureContent(state),
isMature: selectClaimIsNsfwForUri(state, collectionUri),
};
};
const perform = (dispatch) => ({
resolveUri: (uri) => dispatch(doResolveUri(uri)),
resolveCollectionItems: (options) => doFetchItemsInCollection(options),
deleteCollection: (id) => dispatch(doCollectionDelete(id)),
});
export default connect(select, perform)(CollectionPreviewTile);

View file

@ -1,172 +0,0 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import { NavLink, useHistory } from 'react-router-dom';
import ClaimPreviewTile from 'component/claimPreviewTile';
import TruncatedText from 'component/common/truncated-text';
import CollectionCount from './collectionCount';
import CollectionPrivate from './collectionPrivate';
import CollectionMenuList from 'component/collectionMenuList';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import FileThumbnail from 'component/fileThumbnail';
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,
swipeLayout?: boolean,
blackListedOutpoints: Array<{ txid: string, nout: number }>,
filteredOutpoints: Array<{ txid: string, nout: number }>,
blockedChannelUris: Array<string>,
isMature?: boolean,
showMature: boolean,
deleteCollection: (string) => void,
resolveCollectionItems: (any) => void,
isResolvingCollectionClaims: boolean,
};
function CollectionPreviewTile(props: Props) {
const {
uri,
collectionId,
collectionName,
collectionCount,
isResolvingUri,
isResolvingCollectionClaims,
collectionItemUrls,
claim,
resolveCollectionItems,
swipeLayout = false,
} = props;
const { push } = useHistory();
const hasClaim = Boolean(claim);
React.useEffect(() => {
if (collectionId && hasClaim && resolveCollectionItems) {
resolveCollectionItems({ collectionId, page_size: 5 });
}
}, [collectionId, hasClaim, resolveCollectionItems]);
// const signingChannel = claim && claim.signing_channel;
const navigateUrl =
formatLbryUrlForWeb(collectionItemUrls[0] || '/') + (collectionId ? generateListSearchUrlParams(collectionId) : '');
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', {
'swipe-list__item claim-preview--horizontal-tile': swipeLayout,
})}
>
<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 swipeLayout={swipeLayout} uri={uri} />;
}
return (
<li
role="link"
onClick={handleClick}
className={classnames('card claim-preview--tile', {
'swipe-list__item claim-preview--horizontal-tile': swipeLayout,
})}
>
<NavLink {...navLinkProps}>
<FileThumbnail uri={collectionItemUrls && collectionItemUrls.length && collectionItemUrls[0]} tileLayout>
<React.Fragment>
<div className="claim-preview__claim-property-overlay">
<CollectionCount count={collectionCount} />
</div>
</React.Fragment>
</FileThumbnail>
</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

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

View file

@ -1,62 +0,0 @@
// @flow
import * as ICONS from 'constants/icons';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import React from 'react';
import { FormField } from 'component/common/form';
import Icon from 'component/common/icon';
type Props = {
collection: Collection,
hasClaim: boolean,
category: string,
edited: boolean,
editCollection: (string, CollectionEditParams) => void,
uri: string,
collectionPending: Collection,
};
function CollectionSelectItem(props: Props) {
const { collection, hasClaim, category, editCollection, uri, collectionPending } = props;
const { name, id } = collection;
const handleChange = (e) => {
editCollection(id, { uris: [uri], remove: hasClaim });
};
let icon;
switch (category) {
case 'builtin':
icon =
(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
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

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

View file

@ -1,250 +0,0 @@
// @flow
import React from 'react';
import CollectionPreviewTile from 'component/collectionPreviewTile';
import ClaimList from 'component/claimList';
import Button from 'component/button';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as KEYCODES from 'constants/keycodes';
import Yrbl from 'component/yrbl';
import classnames from 'classnames';
import { FormField, Form } from 'component/common/form';
import { useIsMobile } from 'effects/use-screensize';
type Props = {
builtinCollections: CollectionGroup,
publishedCollections: CollectionGroup,
unpublishedCollections: CollectionGroup,
// savedCollections: CollectionGroup,
fetchingCollections: boolean,
};
const LIST_TYPE = Object.freeze({ ALL: 'All', PRIVATE: 'Private', PUBLIC: 'Public' });
const COLLECTION_FILTERS = [LIST_TYPE.ALL, LIST_TYPE.PRIVATE, LIST_TYPE.PUBLIC];
const PLAYLIST_SHOW_COUNT = Object.freeze({ DEFAULT: 12, MOBILE: 6 });
export default function CollectionsListMine(props: Props) {
const {
builtinCollections,
publishedCollections,
unpublishedCollections,
// savedCollections, these are resolved on startup from sync'd claimIds or urls
fetchingCollections,
} = props;
const builtinCollectionsList = (Object.values(builtinCollections || {}): any);
const unpublishedCollectionsList = (Object.keys(unpublishedCollections || {}): any);
const publishedList = (Object.keys(publishedCollections || {}): any);
const hasCollections = unpublishedCollectionsList.length || publishedList.length;
const [filterType, setFilterType] = React.useState(LIST_TYPE.ALL);
const [searchText, setSearchText] = React.useState('');
const isMobileScreen = useIsMobile();
const playlistShowCount = isMobileScreen ? PLAYLIST_SHOW_COUNT.MOBILE : PLAYLIST_SHOW_COUNT.DEFAULT;
const playlistPageUrl = `/$/${PAGES.PLAYLISTS}?type=${filterType}`;
let collectionsToShow = [];
if (filterType === LIST_TYPE.ALL) {
collectionsToShow = unpublishedCollectionsList.concat(publishedList);
} else if (filterType === LIST_TYPE.PRIVATE) {
collectionsToShow = unpublishedCollectionsList;
} else if (filterType === LIST_TYPE.PUBLIC) {
collectionsToShow = publishedList;
}
let filteredCollections;
if (searchText && collectionsToShow) {
filteredCollections = collectionsToShow
.filter((id) => {
return (
(unpublishedCollections[id] &&
unpublishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) ||
(publishedCollections[id] &&
publishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()))
);
})
.slice(0, playlistShowCount);
} else {
filteredCollections = collectionsToShow.slice(0, playlistShowCount) || [];
}
const totalLength = collectionsToShow ? collectionsToShow.length : 0;
const filteredLength = filteredCollections.length;
const isTruncated = totalLength > filteredLength;
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];
function escapeListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ESCAPE) {
e.preventDefault();
setSearchText('');
}
}
function onTextareaFocus() {
window.addEventListener('keydown', escapeListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', escapeListener);
}
return (
<>
{/* Built-in lists */}
{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={
<span className="claim-grid__title-span">
{__(`${list.name}`)}
<div className="claim-grid__title--empty">
<Icon
className="icon--margin-right"
icon={
(list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(list.id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
ICONS.STACK
}
/>
{itemUrls.length}
</div>
</span>
}
/>
</h1>
<ClaimList
swipeLayout={isMobileScreen}
tileLayout
key={list.name}
uris={itemUrls.slice(0, 6)}
collectionId={list.id}
showUnresolvedClaims
/>
</>
)}
{!(itemUrls && itemUrls.length) && (
<h1 className="claim-grid__header claim-grid__title">
{__(`${list.name}`)}
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
</h1>
)}
</>
</div>
);
})}
{/* Playlists: header */}
<div className="claim-grid__wrapper">
<div className="claim-grid__header section">
<h1 className="claim-grid__title">
<Button
className="claim-grid__title"
button="link"
navigate={playlistPageUrl}
label={
<span className="claim-grid__title-span">
{__('Playlists')}
<div className="claim-grid__title--empty">
<Icon className="icon--margin-right" icon={ICONS.STACK} />
</div>
</span>
}
/>
{!hasCollections && !fetchingCollections && (
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
)}
{!hasCollections && fetchingCollections && (
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
)}
</h1>
</div>
{/* Playlists: search */}
<div className="section__header-action-stack">
<div className="section__header--actions">
<div className="claim-search__wrapper">
<div className="claim-search__menu-group">
{COLLECTION_FILTERS.map((value) => (
<Button
label={__(value)}
key={value}
button="alt"
onClick={() => setFilterType(value)}
className={classnames('button-toggle', {
'button-toggle--active': filterType === value,
})}
/>
))}
</div>
</div>
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
name="collection_search"
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
className="wunderbar__input--inline"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
type="text"
placeholder={__('Search')}
/>
</Form>
</div>
<p className="collection-grid__results-summary">
{isTruncated && (
<>
{__('Showing %filtered% results of %total%', { filtered: filteredLength, total: totalLength })}
{`${searchText ? ' (' + __('filtered') + ') ' : ' '}`}
</>
)}
<Button
button="link"
navigate={playlistPageUrl}
label={<span className="claim-grid__title-span">{__('View All Playlists')}</span>}
/>
</p>
</div>
{/* Playlists: tiles */}
{Boolean(hasCollections) && (
<div>
<div
className={classnames('claim-grid', {
'swipe-list': isMobileScreen,
})}
>
{filteredCollections &&
filteredCollections.length > 0 &&
filteredCollections.map((key) => (
<CollectionPreviewTile swipeLayout={isMobileScreen} tileLayout collectionId={key} key={key} />
))}
{!filteredCollections.length && <div className="empty main--empty">{__('No matching playlists')}</div>}
</div>
</div>
)}
{!hasCollections && !fetchingCollections && (
<div className="main--empty">
<Yrbl type={'sad'} title={__('You have no lists yet. Better start hoarding!')} />
</div>
)}
{!hasCollections && fetchingCollections && (
<div className="main--empty">
<h2 className="main--empty empty">{__('Loading...')}</h2>
</div>
)}
</div>
</>
);
}

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { doChannelMute } from 'redux/actions/blocked';
import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments';
import { doOpenModal } from 'redux/actions/app';
import { doSetPlayingUri } from 'redux/actions/content';
import { doClearPlayingUri } from 'redux/actions/content';
import { doToast } from 'redux/actions/notifications';
import { selectClaimIsMine, selectClaimForUri } from 'redux/selectors/claims';
import { selectActiveChannelClaim } from 'redux/selectors/app';
@ -23,7 +23,7 @@ const select = (state, props) => {
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })),
clearPlayingUri: () => dispatch(doClearPlayingUri()),
muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)),
pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)),
commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) =>

View file

@ -79,7 +79,7 @@ class FreezeframeLite {
}
process(freeze) {
return new Promise(resolve => {
return new Promise((resolve) => {
const { $canvas, $image, $container } = freeze;
const { clientWidth, clientHeight } = $image;
@ -159,7 +159,7 @@ class FreezeframeLite {
}
emit(event, items, isPlaying) {
this.eventListeners[event].forEach(cb => {
this.eventListeners[event].forEach((cb) => {
cb(items.length === 1 ? items[0] : items, isPlaying);
});
}

View file

@ -5,7 +5,7 @@ export const isTouch = () => {
return 'ontouchstart' in window || 'onmsgesturechange' in window;
};
export const htmlToNode = html => {
export const htmlToNode = (html) => {
const $wrap = window.document.createElement('div');
$wrap.innerHTML = html;
const $content = $wrap.childNodes;

View file

@ -0,0 +1,38 @@
// @flow
type Props = {
uri: ?string,
isResolvingUri: boolean,
amountNeededForTakeover: number,
};
function BidHelpText(props: Props) {
const { uri, isResolvingUri, amountNeededForTakeover } = props;
let bidHelpText;
if (uri) {
if (isResolvingUri) {
bidHelpText = __('Checking the winning claim amount...');
} else if (amountNeededForTakeover === 0) {
bidHelpText = __('You currently have the highest bid for this name.');
} else if (!amountNeededForTakeover) {
bidHelpText = __(
'Any amount will give you the highest bid, but larger amounts help your content be trusted and discovered.'
);
} else {
bidHelpText = __(
'If you bid more than %amount% LBRY Credits, when someone navigates to %uri%, it will load your published content. However, you can get a longer version of this URL for any bid.',
{
amount: amountNeededForTakeover,
uri: uri,
}
);
}
} else {
bidHelpText = __('These LBRY Credits remain yours and the deposit can be undone at any time.');
}
return bidHelpText;
}
export default BidHelpText;

View file

@ -24,9 +24,14 @@ type Props = {
onClick?: () => void,
children?: Node,
secondPane?: Node,
slimHeader?: boolean,
colorHeader?: boolean,
singlePane?: boolean,
headerActions?: Node,
gridHeader?: boolean,
};
export default function Card(props: Props) {
function Card(props: Props) {
const {
title,
subtitle,
@ -45,7 +50,13 @@ export default function Card(props: Props) {
onClick,
children,
secondPane,
slimHeader,
colorHeader,
singlePane,
headerActions,
gridHeader,
} = props;
const [expanded, setExpanded] = useState(defaultExpand);
const expandable = defaultExpand !== undefined;
@ -63,52 +74,61 @@ export default function Card(props: Props) {
}
}}
>
<div className="card__first-pane">
<FirstPaneWrapper singlePane={singlePane}>
{(title || subtitle) && (
<div
className={classnames('card__header--between', {
'card__header--nowrap': noTitleWrap,
'card__header--slim': slimHeader,
'card__header--bg-color': colorHeader,
'card__header--grid': gridHeader,
})}
>
<div
className={classnames('card__title-section', {
'card__title-section--body-list': isBodyList,
'card__title-section--smallx': smallTitle,
})}
>
<div className={classnames('card__title-section', { 'card__title-section--body-list': isBodyList })}>
{icon && <Icon sectionIcon icon={icon} />}
<div>
{isPageTitle && <h1 className="card__title">{title}</h1>}
{!isPageTitle && (
<h2 className={classnames('card__title', { 'card__title--small': smallTitle })}>{title}</h2>
<div className="card__title-text">
<TitleWrapper isPageTitle={isPageTitle} smallTitle={smallTitle}>
{title}
</TitleWrapper>
{subtitle && (
<div className={classnames('card__subtitle', { 'card__subtitle--small': smallTitle })}>
{subtitle}
</div>
)}
{subtitle && <div className="card__subtitle">{subtitle}</div>}
</div>
</div>
<div className="card__title-actions-container">
{titleActions && (
<div
className={classnames('card__title-actions', {
'card__title-actions--small': smallTitle,
})}
>
{titleActions}
</div>
)}
{expandable && (
<div className="card__title-actions">
<Button
button="alt"
aria-expanded={expanded}
aria-label={expanded ? __('Less') : __('More')}
icon={expanded ? ICONS.SUBTRACT : ICONS.ADD}
onClick={() => setExpanded(!expanded)}
/>
</div>
)}
</div>
{(titleActions || expandable) && (
<div className="card__title-actions-container">
{titleActions && (
<div
className={classnames('card__title-actions', {
'card__title-actions--small': smallTitle,
})}
>
{titleActions}
</div>
)}
{expandable && (
<div className="card__title-actions">
<Button
button="alt"
aria-expanded={expanded}
aria-label={expanded ? __('Less') : __('More')}
icon={expanded ? ICONS.SUBTRACT : ICONS.ADD}
onClick={() => setExpanded(!expanded)}
/>
</div>
)}
</div>
)}
{headerActions}
</div>
)}
{(!expandable || (expandable && expanded)) && (
<>
{body && (
@ -125,9 +145,40 @@ export default function Card(props: Props) {
{children && <div className="card__main-actions">{children}</div>}
</>
)}
{nag}
</div>
</FirstPaneWrapper>
{secondPane && <div className="card__second-pane">{secondPane}</div>}
</section>
);
}
type FirstPaneProps = {
singlePane?: boolean,
children: any,
};
const FirstPaneWrapper = (props: FirstPaneProps) => {
const { singlePane, children } = props;
return singlePane ? children : <div className="card__first-pane">{children}</div>;
};
type TitleProps = {
isPageTitle?: boolean,
smallTitle?: boolean,
children?: any,
};
const TitleWrapper = (props: TitleProps) => {
const { isPageTitle, smallTitle, children } = props;
return isPageTitle ? (
<h1 className="card__title">{children}</h1>
) : (
<h2 className={classnames('card__title', { 'card__title--small': smallTitle })}>{children}</h2>
);
};
export default Card;

View file

@ -3,14 +3,18 @@ import classnames from 'classnames';
import React from 'react';
type Props = {
isChannel: boolean,
type: string,
isChannel?: boolean,
type?: string,
WrapperElement?: string,
xsmall?: boolean,
};
function ClaimPreviewLoading(props: Props) {
const { isChannel, type } = props;
const { isChannel, type, WrapperElement = 'li', xsmall } = props;
return (
<li
// Uses the same WrapperElement as claimPreview so it's consistent with the rest of the list components
<WrapperElement
className={classnames('placeholder claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline',
@ -18,7 +22,7 @@ function ClaimPreviewLoading(props: Props) {
})}
>
<div className={classnames('claim-preview', { 'claim-preview--large': type === 'large' })}>
<div className="media__thumb" />
<div className={classnames('media__thumb', { 'media__thumb--small': xsmall })} />
<div className="placeholder__wrapper">
<div className="claim-preview__title" />
<div className="claim-preview__title_b" />
@ -31,7 +35,7 @@ function ClaimPreviewLoading(props: Props) {
</div>
</div>
</div>
</li>
</WrapperElement>
);
}

View file

@ -0,0 +1,13 @@
// @flow
import React from 'react';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
const CollectionPrivateIcon = () => (
<div className="claim-preview__overlay-properties--small visibility-icon">
<Icon icon={ICONS.LOCK} />
<span>{__('Private')}</span>
</div>
);
export default CollectionPrivateIcon;

View file

@ -0,0 +1,46 @@
// @flow
import React from 'react';
import Button from 'component/button';
import Tooltip from 'component/common/tooltip';
type Props = {
title: string,
iconSize?: number,
noStyle?: boolean,
navigate?: string,
requiresAuth?: boolean,
requiresChannel?: boolean,
};
function FileActionButton(props: Props) {
const { title, iconSize, noStyle, ...buttonProps } = props;
const { navigate, requiresAuth, requiresChannel } = buttonProps;
if (navigate || requiresAuth || requiresChannel) {
return (
<Tooltip title={title} arrow={false} enterDelay={100}>
<div className="button--file-action--tooltip-wrapper">
<Button
button={noStyle ? 'alt' : undefined}
className={noStyle ? undefined : 'button--file-action button--file-action--tooltip'}
iconSize={iconSize || 16}
{...buttonProps}
/>
</div>
</Tooltip>
);
}
return (
<Tooltip title={title} arrow={false} enterDelay={100}>
<Button
button={noStyle ? 'alt' : undefined}
className={noStyle ? undefined : 'button--file-action'}
iconSize={iconSize || 16}
{...buttonProps}
/>
</Tooltip>
);
}
export default FileActionButton;

View file

@ -1,14 +1,20 @@
// @flow
import React, { useEffect } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import Freezeframe from './FreezeframeLite';
import useLazyLoading from 'effects/use-lazy-loading';
const FreezeframeWrapper = (props) => {
type Props = {
src: string,
className: string,
children: any,
};
const FreezeframeWrapper = (props: Props) => {
const { src, className, children } = props;
const imgRef = React.useRef();
const freezeframe = React.useRef();
// eslint-disable-next-line
const { src, className, children } = props;
const srcLoaded = useLazyLoading(imgRef);
@ -20,17 +26,10 @@ const FreezeframeWrapper = (props) => {
return (
<div className={classnames(className, 'freezeframe-wrapper')}>
<>
<img ref={imgRef} data-src={src} className="freezeframe-img" />
{children}
</>
<img ref={imgRef} data-src={src} className="freezeframe-img" />
{children}
</div>
);
};
FreezeframeWrapper.propTypes = {
src: PropTypes.string.isRequired,
className: PropTypes.string.isRequired,
};
export default FreezeframeWrapper;

View file

@ -2500,6 +2500,7 @@ export const icons = {
strokeLinejoin="round"
stroke="currentColor"
fill="none"
style={{ overflow: 'visible' }}
>
<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" />
@ -2668,25 +2669,55 @@ export const icons = {
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</g>
),
[ICONS.REPEAT]: buildIcon(
<g>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</g>
),
[ICONS.SHUFFLE]: buildIcon(
<g>
<polyline points="16 3 21 3 21 8" />
<line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" />
<line x1="15" y1="15" x2="21" y2="21" />
<line x1="4" y1="4" x2="9" y2="9" />
</g>
),
[ICONS.REPEAT]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
stroke={color}
{...otherProps}
>
<polyline stroke={color} points="17 1 21 5 17 9" />
<path stroke={color} d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline stroke={color} points="7 23 3 19 7 15" />
<path stroke={color} d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
},
[ICONS.SHUFFLE]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
stroke={color}
{...otherProps}
>
<polyline stroke={color} points="16 3 21 3 21 8" />
<line stroke={color} x1="4" y1="20" x2="21" y2="3" />
<polyline stroke={color} points="21 16 21 21 16 21" />
<line stroke={color} x1="15" y1="15" x2="21" y2="21" />
<line stroke={color} x1="4" y1="4" x2="9" y2="9" />
</svg>
);
},
[ICONS.HOLD_PHONE]: buildIcon(
<g>
<svg>
<path d="M12 17.491L1.5 17.491" />
<path d="M8,19.241H8a.25.25,0,0,1,.25.25h0a.25.25,0,0,1-.25.25H8a.25.25,0,0,1-.25-.25h0a.25.25,0,0,1,.25-.25" />
<path d="M12.5,21.491h-9a2,2,0,0,1-2-2v-17a2,2,0,0,1,2-2h9a2,2,0,0,1,2,2V13.265" />
@ -2694,7 +2725,7 @@ export const icons = {
<path d="M16.5,14.868l-3.727-2.987a1.331,1.331,0,0,0-1.883,1.883l3.61,4.079V20.4c0,1.206,1.724,3.111,1.724,3.111" />
<path d="M5.750 5.741 A2.250 2.250 0 1 0 10.250 5.741 A2.250 2.250 0 1 0 5.750 5.741 Z" />
<path d="M12.11,11.524a4.628,4.628,0,0,0-8.61.967" />
</g>
</svg>
),
[ICONS.LIFE]: buildIcon(
<g>
@ -3036,6 +3067,41 @@ export const icons = {
</g>
</svg>
),
[ICONS.PLAYLIST_PLAYBACK]: (props: IconProps) => {
const { size = 50, color = 'currentColor', ...otherProps } = props;
return (
<svg
{...otherProps}
xmlns="http://www.w3.org/2000/svg"
viewBox="5 0 30 30"
width={size}
height={size === 30 ? 18 : 30}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path style={{ fill: color }} d="M13.9,20.9v-9.7l6.9,4.7L13.9,20.9z M15.1,13.4v5.1l3.7-2.7L15.1,13.4z" />
<path
style={{ fill: color }}
d="M29,16c0,5.3-3.2,9.8-7.7,11.9l0.3,1.4c5.2-2.2,8.8-7.3,8.8-13.3c0-6-3.7-11.1-8.9-13.3l-0.3,1.4C25.8,6.1,29,10.7,29,16z"
/>
<path
style={{ fill: color }}
d="M26,16c0-3.9-2.2-7.2-5.4-8.9l-0.3,1.4c2.6,1.5,4.3,4.3,4.3,7.5c0,3.2-1.7,5.9-4.3,7.4l0.3,1.4C23.8,23.2,26,19.9,26,16z"
/>
<path
style={{ fill: color }}
d="M3,16c0-5.3,3.2-9.9,7.8-11.9l-0.3-1.4C5.3,4.9,1.6,10,1.6,16c0,6,3.6,11.1,8.8,13.3l0.3-1.4C6.2,25.8,3,21.3,3,16z"
/>
<path
style={{ fill: color }}
d="M6,16c0,3.9,2.2,7.2,5.4,8.9l0.3-1.4c-2.6-1.5-4.3-4.3-4.3-7.4c0-3.2,1.8-6,4.4-7.5l-0.3-1.4C8.2,8.8,6,12.1,6,16z"
/>
</svg>
);
},
[ICONS.PREMIUM]: (props: CustomProps) => (
<svg
{...props}
@ -3302,4 +3368,109 @@ export const icons = {
<path d="M0.6,11.8c0-6,5-11,11-11 M9.6,7.2v9.5l6.9-4.7L9.6,7.2z M-2.1,9.5l2.9,2.9l3.2-2.7 M11.4,23.2 v-0.9 M5.6,21.5L6,20.6 M2.1,16.4l-0.8,0.4 M17,20.8l0.5,0.8 M20.9,16.7l0.8,0.5 M23.1,11l-0.9,0.1 M21,5.2l-0.7,0.5 M16.2,1.2 L15.8,2" />
</svg>
),
[ICONS.PLAYLIST]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
fill={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
stroke={color}
style={{ overflow: 'visible' }}
{...otherProps}
>
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: 'none' }} />
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
<path
d="M3.75,7.64V3.86a.36.36,0,0,1,.55-.31L7.57,5.44a.36.36,0,0,1,0,.62L4.3,8A.36.36,0,0,1,3.75,7.64Z"
style={{ fill: 'none' }}
/>
</g>
</svg>
);
},
[ICONS.PLAYLIST_ADD]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
fill={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: 'visible' }}
{...otherProps}
>
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: 'none' }} />
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
<line x1="5.75" y1="3" x2="5.75" y2="8.5" />
<line x1="3" y1="5.75" x2="8.5" y2="5.75" />
</g>
</svg>
);
},
[ICONS.PLAYLIST_FILLED]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
fill={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: 'visible' }}
{...otherProps}
>
<g transform="matrix(3.4285714285714284,0,0,3.4285714285714284,0,0)">
<rect x="0.5" y="0.5" width="10.5" height="10.5" rx="1" style={{ fill: color }} />
<path d="M13.5,3.5v9a1,1,0,0,1-1,1h-9" style={{ fill: 'none' }} />
<path
d="M3.75,7.64V3.86a.36.36,0,0,1,.55-.31L7.57,5.44a.36.36,0,0,1,0,.62L4.3,8A.36.36,0,0,1,3.75,7.64Z"
style={{ stroke: 'var(--color-header-background)', strokeWidth: 1.2 }}
/>
</g>
</svg>
);
},
[ICONS.ARRANGE]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
width={size}
height={size}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke={color}
{...otherProps}
>
<path
strokeWidth="1.5"
d="M0.5 1.42857C0.5 0.915736 0.915736 0.5 1.42857 0.5H12.5714C13.0843 0.5 13.5 0.915736 13.5 1.42857V12.5714C13.5 13.0843 13.0843 13.5 12.5714 13.5H1.42857C0.915736 13.5 0.5 13.0843 0.5 12.5714V1.42857Z"
/>
<path d="M8.85715 5.14279L7.00001 3.28564L5.14287 5.14279" />
<path d="M8.85715 8.85742L7.00001 10.7146L5.14287 8.85742" />
<path d="M7.00002 3.28564V10.7142" />
</svg>
);
},
};

View file

@ -0,0 +1,10 @@
// @flow
import React from 'react';
const SectionDivider = () => (
<div className="section__divider">
<hr />
</div>
);
export default SectionDivider;

View file

@ -6,13 +6,14 @@ type Props = {
lines: number,
showTooltip?: boolean,
children?: React.Node,
style?: any,
};
const TruncatedText = (props: Props) => {
const { text, children, lines, showTooltip } = props;
const { text, children, lines, showTooltip, style } = props;
const tooltip = showTooltip ? children || text : '';
return (
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines }}>
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines, ...style }}>
{children || text}
</span>
);

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import ClaimDeleteButton from './view';
const perform = {
doOpenModal,
};
export default connect(null, perform)(ClaimDeleteButton);

View file

@ -0,0 +1,25 @@
// @flow
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import React from 'react';
import FileActionButton from 'component/common/file-action-button';
type Props = {
uri: string,
// redux
doOpenModal: (id: string, {}) => void,
};
function ClaimDeleteButton(props: Props) {
const { uri, doOpenModal } = props;
return (
<FileActionButton
title={__('Remove from your library')}
icon={ICONS.DELETE}
onClick={() => doOpenModal(MODALS.CONFIRM_FILE_REMOVE, { uri })}
/>
);
}
export default ClaimDeleteButton;

View file

@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import { doPrepareEdit } from 'redux/actions/publish';
import { selectClaimForUri, selectChannelNameForClaimUri, selectClaimIsMineForUri } from 'redux/selectors/claims';
import ClaimPublishButton from './view';
const select = (state, props) => {
const { uri } = props;
const claim = selectClaimForUri(state, uri);
return {
claim,
channelName: selectChannelNameForClaimUri(state, uri),
claimIsMine: selectClaimIsMineForUri(state, uri),
};
};
const perform = {
doPrepareEdit,
};
export default connect(select, perform)(ClaimPublishButton);

View file

@ -0,0 +1,46 @@
// @flow
import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons';
import { buildURI } from 'util/lbryURI';
import React from 'react';
import FileActionButton from 'component/common/file-action-button';
type Props = {
isLivestreamClaim: boolean,
// redux
claim: ?Claim,
channelName: ?string,
claimIsMine: boolean,
doPrepareEdit: (claim: Claim, uri: string) => void,
};
function ClaimPublishButton(props: Props) {
const { isLivestreamClaim, claim, channelName, claimIsMine, doPrepareEdit } = props;
// We want to use the short form uri for editing
// This is what the user is used to seeing, they don't care about the claim id
// We will select the claim id before they publish
let editUri;
if (claim && claimIsMine) {
const { name: claimName, claim_id: claimId } = claim;
const uriObject: LbryUrlObj = { streamName: claimName, streamClaimId: claimId };
if (channelName) {
uriObject.channelName = channelName;
}
editUri = buildURI(uriObject);
}
return (
<FileActionButton
title={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
icon={ICONS.EDIT}
navigate={`/$/${PAGES.UPLOAD}`}
onClick={!claim ? undefined : () => doPrepareEdit(claim, editUri)}
/>
);
}
export default ClaimPublishButton;

View file

@ -5,7 +5,6 @@ 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 { buildURI } from 'util/lbryURI';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import * as RENDER_MODES from 'constants/file_render_modes';
@ -16,7 +15,10 @@ import FileReactions from 'component/fileReactions';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import { webDownloadClaim } from 'util/downloadClaim';
import Tooltip from 'component/common/tooltip';
import ClaimShareButton from 'component/claimShareButton';
import ClaimRepostButton from 'component/claimRepostButton';
import ClaimPublishButton from './internal/claimPublishButton';
import ClaimDeleteButton from './internal/claimDeleteButton';
type Props = {
uri: string,
@ -121,53 +123,16 @@ export default function FileActions(props: Props) {
{!isAPreorder && <ClaimSupportButton uri={uri} fileAction />}
<ClaimCollectionAddButton uri={uri} fileAction />
<ClaimCollectionAddButton uri={uri} />
{!hideRepost && !isMobile && !isLivestreamClaim && (
<Tooltip title={__('Repost')} arrow={false}>
<Button
button="alt"
className="button--file-action"
icon={ICONS.REPOST}
label={
claimMeta.reposted > 1 ? __(`%repost_total% Reposts`, { repost_total: claimMeta.reposted }) : __('Repost')
}
requiresAuth
onClick={handleRepostClick}
/>
</Tooltip>
)}
{!hideRepost && !isMobile && !isLivestreamClaim && <ClaimRepostButton uri={uri} />}
<Tooltip title={__('Share')} arrow={false}>
<Button
className="button--file-action"
icon={ICONS.SHARE}
label={__('Share')}
onClick={() => doOpenModal(MODALS.SOCIAL_SHARE, { uri, webShareable, collectionId })}
/>
</Tooltip>
<ClaimShareButton uri={uri} fileAction webShareable={webShareable} collectionId={collectionId} />
{claimIsMine && !isMobile && (
<>
<Tooltip title={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')} arrow={false}>
<div style={{ margin: '0px' }}>
<Button
className="button--file-action"
icon={ICONS.EDIT}
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
onClick={() => doPrepareEdit(claim, editUri, claimType)}
/>
</div>
</Tooltip>
<Tooltip title={__('Remove from your library')} arrow={false}>
<Button
className="button--file-action"
icon={ICONS.DELETE}
description={__('Delete')}
onClick={() => doOpenModal(MODALS.CONFIRM_FILE_REMOVE, { uri })}
/>
</Tooltip>
<ClaimPublishButton uri={uri} isLivestreamClaim={isLivestreamClaim} />
<ClaimDeleteButton uri={uri} />
</>
)}

View file

@ -8,7 +8,7 @@ import {
} from 'redux/selectors/file_info';
import { selectCostInfoForUri } from 'lbryinc';
import { doOpenModal } from 'redux/actions/app';
import { doSetPlayingUri, doDownloadUri } from 'redux/actions/content';
import { doClearPlayingUri, doDownloadUri } from 'redux/actions/content';
import FileDownloadLink from './view';
const select = (state, props) => {
@ -27,7 +27,7 @@ const select = (state, props) => {
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
pause: () => dispatch(doSetPlayingUri({ uri: null })),
pause: () => dispatch(doClearPlayingUri()),
download: (uri) => dispatch(doDownloadUri(uri)),
});

View file

@ -3,11 +3,10 @@ import * as REACTION_TYPES from 'constants/reactions';
import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import Button from 'component/button';
import RatioBar from 'component/ratioBar';
import { formatNumberWithCommas } from 'util/number';
import NudgeFloating from 'component/nudgeFloating';
// import Tooltip from 'component/common/tooltip';
import FileActionButton from 'component/common/file-action-button';
const LIVE_REACTION_FETCH_MS = 1000 * 45;
@ -41,39 +40,6 @@ export default function FileReactions(props: Props) {
doReactionDislike,
} = props;
const likeIcon = myReaction === REACTION_TYPES.LIKE ? ICONS.FIRE_ACTIVE : ICONS.FIRE;
const dislikeIcon = myReaction === REACTION_TYPES.DISLIKE ? ICONS.SLIME_ACTIVE : ICONS.SLIME;
const likeLabel = (
<>
{myReaction === REACTION_TYPES.LIKE && (
<>
<div className="button__fire-glow" />
<div className="button__fire-particle1" />
<div className="button__fire-particle2" />
<div className="button__fire-particle3" />
<div className="button__fire-particle4" />
<div className="button__fire-particle5" />
<div className="button__fire-particle6" />
</>
)}
<span>{formatNumberWithCommas(likeCount, 0)}</span>
</>
);
const dislikeLabel = (
<>
{myReaction === REACTION_TYPES.DISLIKE && (
<>
<div className="button__slime-stain" />
<div className="button__slime-drop1" />
<div className="button__slime-drop2" />
</>
)}
<span>{formatNumberWithCommas(dislikeCount, 0)}</span>
</>
);
React.useEffect(() => {
function fetchReactions() {
doFetchReactions(claimId);
@ -104,64 +70,81 @@ export default function FileReactions(props: Props) {
/>
)}
<div className={'ratio-wrapper'}>
<Button
title={__('I like this')}
requiresAuth={IS_WEB}
authSrc="filereaction_like"
className={classnames('button--file-action button-like', {
'button--fire': myReaction === REACTION_TYPES.LIKE,
})}
label={likeLabel}
iconSize={18}
icon={likeIcon}
onClick={() => doReactionLike(uri)}
/>
<Button
requiresAuth={IS_WEB}
authSrc={'filereaction_dislike'}
title={__('I dislike this')}
className={classnames('button--file-action button-dislike', {
'button--slime': myReaction === REACTION_TYPES.DISLIKE,
})}
label={dislikeLabel}
iconSize={18}
icon={dislikeIcon}
onClick={() => doReactionDislike(uri)}
/>
<div className="ratio-wrapper">
<LikeButton myReaction={myReaction} reactionCount={likeCount} onClick={() => doReactionLike(uri)} />
<DislikeButton myReaction={myReaction} reactionCount={dislikeCount} onClick={() => doReactionDislike(uri)} />
<RatioBar likeCount={likeCount} dislikeCount={dislikeCount} />
</div>
</>
);
}
/*
type ReactionProps = {
title: string,
label: any,
icon: string,
isActive: boolean,
activeClassName: string,
type ButtonProps = {
myReaction: ?string,
reactionCount: number,
onClick: () => void,
};
const FileReaction = (reactionProps: ReactionProps) => {
const { title, label, icon, isActive, activeClassName, onClick } = reactionProps;
const LikeButton = (props: ButtonProps) => {
const { myReaction, reactionCount, onClick } = props;
return (
<Tooltip title={title} arrow={false}>
<div className="file-reaction__tooltip-inner">
<Button
requiresAuth
authSrc="filereaction_like"
className={classnames('button--file-action', { [activeClassName]: isActive })}
label={label}
iconSize={18}
icon={icon}
onClick={onClick}
/>
</div>
</Tooltip>
<FileActionButton
title={__('I like this')}
requiresAuth
authSrc="filereaction_like"
className={classnames('button--file-action button-like', {
'button--fire': myReaction === REACTION_TYPES.LIKE,
})}
label={
<>
{myReaction === REACTION_TYPES.LIKE && (
<>
<div className="button__fire-glow" />
<div className="button__fire-particle1" />
<div className="button__fire-particle2" />
<div className="button__fire-particle3" />
<div className="button__fire-particle4" />
<div className="button__fire-particle5" />
<div className="button__fire-particle6" />
</>
)}
<span>{formatNumberWithCommas(reactionCount, 0)}</span>
</>
}
iconSize={18}
icon={myReaction === REACTION_TYPES.LIKE ? ICONS.FIRE_ACTIVE : ICONS.FIRE}
onClick={onClick}
/>
);
};
const DislikeButton = (props: ButtonProps) => {
const { myReaction, reactionCount, onClick } = props;
return (
<FileActionButton
requiresAuth
authSrc={'filereaction_dislike'}
title={__('I dislike this')}
className={classnames('button--file-action button-dislike', {
'button--slime': myReaction === REACTION_TYPES.DISLIKE,
})}
label={
<>
{myReaction === REACTION_TYPES.DISLIKE && (
<>
<div className="button__slime-stain" />
<div className="button__slime-drop1" />
<div className="button__slime-drop2" />
</>
)}
<span>{formatNumberWithCommas(reactionCount, 0)}</span>
</>
}
iconSize={18}
icon={myReaction === REACTION_TYPES.DISLIKE ? ICONS.SLIME_ACTIVE : ICONS.SLIME}
onClick={onClick}
/>
);
};
*/

View file

@ -4,13 +4,18 @@ import {
selectTitleForUri,
selectClaimWasPurchasedForUri,
selectGeoRestrictionForUri,
selectClaimIsNsfwForUri,
} from 'redux/selectors/claims';
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
import {
makeSelectNextUrlForCollectionAndUrl,
makeSelectPreviousUrlForCollectionAndUrl,
selectCollectionForId,
selectNextUrlForCollectionAndUrl,
selectPreviousUrlForCollectionAndUrl,
selectCollectionForIdHasClaimUrl,
selectFirstItemUrlForCollection,
} from 'redux/selectors/collections';
import * as SETTINGS from 'constants/settings';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import {
makeSelectIsPlayerFloating,
selectPrimaryUri,
@ -18,24 +23,32 @@ import {
makeSelectFileRenderModeForUri,
} from 'redux/selectors/content';
import { selectClientSetting } from 'redux/selectors/settings';
import { doClearQueueList } from 'redux/actions/collections';
import { selectCostInfoForUri } from 'lbryinc';
import { doUriInitiatePlay, doSetPlayingUri, doClearPlayingUri } from 'redux/actions/content';
import { doFetchRecommendedContent } from 'redux/actions/search';
import { withRouter } from 'react-router';
import { selectAppDrawerOpen } from 'redux/selectors/app';
import { selectHasAppDrawerOpen, selectMainPlayerDimensions } from 'redux/selectors/app';
import { selectIsActiveLivestreamForUri, selectSocketConnectionForId } from 'redux/selectors/livestream';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { isStreamPlaceholderClaim, getVideoClaimAspectRatio } from 'util/claim';
import { doOpenModal } from 'redux/actions/app';
import FileRenderFloating from './view';
const select = (state, props) => {
const { location } = props;
const { search } = location;
const urlParams = new URLSearchParams(search);
const collectionSidebarId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
const playingUri = selectPlayingUri(state);
const { uri, collectionId } = playingUri || {};
const {
uri,
collection: { collectionId },
} = playingUri;
const claim = uri && selectClaimForUri(state, uri);
const { claim_id: claimId, signing_channel: channelClaim } = claim || {};
const { claim_id: claimId, signing_channel: channelClaim, permanent_url } = claim || {};
const { canonical_url: channelUrl } = channelClaim || {};
return {
@ -47,20 +60,27 @@ const select = (state, props) => {
title: selectTitleForUri(state, uri),
isFloating: makeSelectIsPlayerFloating(location)(state),
streamingUrl: makeSelectStreamingUrlForUri(uri)(state),
floatingPlayerEnabled: selectClientSetting(state, SETTINGS.FLOATING_PLAYER),
floatingPlayerEnabled: playingUri.source === 'queue' || selectClientSetting(state, SETTINGS.FLOATING_PLAYER),
renderMode: makeSelectFileRenderModeForUri(uri)(state),
videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE),
costInfo: selectCostInfoForUri(state, uri),
claimWasPurchased: selectClaimWasPurchasedForUri(state, uri),
nextListUri: collectionId && makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state),
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state),
nextListUri: collectionId && selectNextUrlForCollectionAndUrl(state, uri, collectionId),
previousListUri: collectionId && selectPreviousUrlForCollectionAndUrl(state, uri, collectionId),
collectionId,
collectionSidebarId,
playingCollection: selectCollectionForId(state, collectionId),
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
videoAspectRatio: getVideoClaimAspectRatio(claim),
socketConnection: selectSocketConnectionForId(state, claimId),
isLivestreamClaim: isStreamPlaceholderClaim(claim),
geoRestriction: selectGeoRestrictionForUri(state, uri),
appDrawerOpen: selectAppDrawerOpen(state),
appDrawerOpen: selectHasAppDrawerOpen(state),
hasClaimInQueue:
permanent_url && selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.QUEUE_ID, permanent_url),
mainPlayerDimensions: selectMainPlayerDimensions(state),
firstCollectionItemUrl: selectFirstItemUrlForCollection(state, collectionId),
isMature: selectClaimIsNsfwForUri(state, uri),
};
};
@ -71,6 +91,8 @@ const perform = {
doCommentSocketConnect,
doCommentSocketDisconnect,
doClearPlayingUri,
doClearQueueList,
doOpenModal,
};
export default withRouter(connect(select, perform)(FileRenderFloating));

View file

@ -4,8 +4,10 @@
import { Global } from '@emotion/react';
import type { ElementRef } from 'react';
import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons';
import * as RENDER_MODES from 'constants/file_render_modes';
import { DEFAULT_INITIAL_FLOATING_POS } from 'constants/player';
import React from 'react';
import Button from 'component/button';
import classnames from 'classnames';
@ -19,7 +21,6 @@ import { onFullscreenChange } from 'util/full-screen';
import { generateListSearchUrlParams, formatLbryChannelName } from 'util/url';
import { useIsMobile, useIsMobileLandscape, useIsLandscapeScreen } from 'effects/use-screensize';
import debounce from 'util/debounce';
import { useHistory } from 'react-router';
import { isURIEqual } from 'util/lbryURI';
import AutoplayCountdown from 'component/autoplayCountdown';
import usePlayNext from 'effects/use-play-next';
@ -32,7 +33,8 @@ import {
getMaxLandscapeHeight,
getAmountNeededToCenterVideo,
getPossiblePlayerHeight,
} from './helper-functions';
} from 'util/window';
import PlaylistCard from 'component/playlistCard';
// scss/init/vars.scss
// --header-height
@ -62,6 +64,7 @@ type Props = {
primaryUri: ?string,
videoTheaterMode: boolean,
collectionId: string,
collectionSidebarId: ?string,
costInfo: any,
claimWasPurchased: boolean,
nextListUri: string,
@ -75,9 +78,16 @@ type Props = {
isLivestreamClaim: boolean,
geoRestriction: ?GeoRestriction,
appDrawerOpen: boolean,
playingCollection: Collection,
hasClaimInQueue: boolean,
mainPlayerDimensions: { height: number, width: number },
firstCollectionItemUrl: ?string,
isMature: boolean,
doCommentSocketConnect: (string, string, string) => void,
doCommentSocketDisconnect: (string, string) => void,
doClearPlayingUri: () => void,
doClearQueueList: () => void,
doOpenModal: (id: string, {}) => void,
};
export default function FileRenderFloating(props: Props) {
@ -94,6 +104,7 @@ export default function FileRenderFloating(props: Props) {
primaryUri,
videoTheaterMode,
collectionId,
collectionSidebarId,
costInfo,
claimWasPurchased,
nextListUri,
@ -107,9 +118,16 @@ export default function FileRenderFloating(props: Props) {
videoAspectRatio,
geoRestriction,
appDrawerOpen,
playingCollection,
hasClaimInQueue,
mainPlayerDimensions,
firstCollectionItemUrl,
isMature,
doCommentSocketConnect,
doCommentSocketDisconnect,
doClearPlayingUri,
doClearQueueList,
doOpenModal,
} = props;
const isMobile = useIsMobile();
@ -120,33 +138,27 @@ export default function FileRenderFloating(props: Props) {
const initialPlayerHeight = React.useRef();
const resizedBetweenFloating = React.useRef();
const {
location: { state },
} = useHistory();
const hideFloatingPlayer = state && state.hideFloatingPlayer;
const { uri: playingUrl, source: playingUriSource, primaryUri: playingPrimaryUri } = playingUri;
const isComment = playingUriSource === 'comment';
const mainFilePlaying = Boolean(!isFloating && primaryUri && isURIEqual(uri, primaryUri));
const noFloatingPlayer = !isFloating || !floatingPlayerEnabled || hideFloatingPlayer;
const noFloatingPlayer = !isFloating || !floatingPlayerEnabled;
const [fileViewerRect, setFileViewerRect] = React.useState();
const [wasDragging, setWasDragging] = React.useState(false);
const [doNavigate, setDoNavigate] = React.useState(false);
const [shouldPlayNext, setPlayNext] = React.useState(true);
const [countdownCanceled, setCountdownCanceled] = React.useState(false);
const [position, setPosition] = usePersistedState('floating-file-viewer:position', {
x: -25,
y: window.innerHeight - 400,
});
const relativePosRef = React.useRef({ x: 0, y: 0 });
const [forceDisable, setForceDisable] = React.useState(false);
const [position, setPosition] = usePersistedState('floating-file-viewer:position', DEFAULT_INITIAL_FLOATING_POS);
const relativePosRef = React.useRef(calculateRelativePos(position.x, position.y));
const noPlayerHeight = fileViewerRect?.height === 0;
const navigateUrl =
(playingPrimaryUri || playingUrl || '') + (collectionId ? generateListSearchUrlParams(collectionId) : '');
const isFree = costInfo && costInfo.cost === 0;
const isLoading = !costInfo || (!streamingUrl && !costInfo.cost);
const canViewFile = isFree || claimWasPurchased;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode) || isCurrentClaimLive;
const isReadyToPlay = isCurrentClaimLive || (isPlayable && streamingUrl);
@ -158,10 +170,14 @@ export default function FileRenderFloating(props: Props) {
// ****************************************************************************
const handleResize = React.useCallback(() => {
const element = mainFilePlaying
? document.querySelector(`.${PRIMARY_PLAYER_WRAPPER_CLASS}`)
const filePageElement = document.querySelector(`.${PRIMARY_PLAYER_WRAPPER_CLASS}`);
const playingElement = mainFilePlaying
? filePageElement
: document.querySelector(`.${INLINE_PLAYER_WRAPPER_CLASS}`);
const element = playingElement || filePageElement;
if (!element) return;
const rect = element.getBoundingClientRect();
@ -198,8 +214,11 @@ export default function FileRenderFloating(props: Props) {
const newX = Math.round(relativePosRef.current.x * screenW);
const newY = Math.round(relativePosRef.current.y * screenH);
const clampPosition = clampFloatingPlayerToScreen({ x: newX, y: newY });
setPosition(clampFloatingPlayerToScreen(newX, newY));
if (![clampPosition.x, clampPosition.y].some(isNaN)) {
setPosition(clampPosition);
}
}, [setPosition]);
const clampToScreenOnResize = React.useCallback(
@ -222,7 +241,7 @@ export default function FileRenderFloating(props: Props) {
isFloating,
collectionId,
shouldPlayNext,
nextListUri,
nextListUri || firstCollectionItemUrl,
previousListUri,
doNavigate,
doUriInitiatePlay,
@ -258,10 +277,11 @@ export default function FileRenderFloating(props: Props) {
]);
React.useEffect(() => {
if (playingPrimaryUri || playingUrl || noPlayerHeight) {
if (playingPrimaryUri || playingUrl || noPlayerHeight || collectionSidebarId) {
handleResize();
setCountdownCanceled(false);
}
}, [handleResize, playingPrimaryUri, theaterMode, playingUrl, noPlayerHeight]);
}, [handleResize, playingPrimaryUri, theaterMode, playingUrl, noPlayerHeight, collectionSidebarId]);
// Listen to main-window resizing and adjust the floating player position accordingly:
React.useEffect(() => {
@ -278,7 +298,8 @@ export default function FileRenderFloating(props: Props) {
}
function onWindowResize() {
return isFloating ? clampToScreenOnResize() : handleResize();
if (isFloating) clampToScreenOnResize();
if (collectionSidebarId || !isFloating) handleResize();
}
window.addEventListener('resize', onWindowResize);
@ -290,7 +311,7 @@ export default function FileRenderFloating(props: Props) {
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clampToScreenOnResize, handleResize, isFloating]);
}, [clampToScreenOnResize, handleResize, isFloating, collectionSidebarId]);
React.useEffect(() => {
// Initial update for relativePosRef:
@ -324,15 +345,15 @@ export default function FileRenderFloating(props: Props) {
}, [playingUrl]);
React.useEffect(() => {
if (!primaryUri && !floatingPlayerEnabled && playingUrl && !playingUriSource) {
if (primaryUri && uri && primaryUri !== uri && !floatingPlayerEnabled && playingUrl) {
doClearPlayingUri();
}
}, [doClearPlayingUri, floatingPlayerEnabled, playingUriSource, playingUrl, primaryUri]);
}, [doClearPlayingUri, floatingPlayerEnabled, playingUrl, primaryUri, uri]);
if (
geoRestriction ||
!isPlayable ||
!uri ||
(!isPlayable && !collectionSidebarId) ||
(!uri && !collectionSidebarId) ||
(isFloating && noFloatingPlayer) ||
(collectionId && !isFloating && ((!canViewFile && !nextListUri) || countdownCanceled)) ||
(isLivestreamClaim && !isCurrentClaimLive)
@ -366,7 +387,7 @@ export default function FileRenderFloating(props: Props) {
let newPos = { x, y };
if (newPos.x !== position.x || newPos.y !== position.y) {
newPos = clampFloatingPlayerToScreen(newPos.x, newPos.y);
newPos = clampFloatingPlayerToScreen(newPos);
setPosition(newPos);
relativePosRef.current = calculateRelativePos(newPos.x, newPos.y);
@ -374,97 +395,140 @@ export default function FileRenderFloating(props: Props) {
}
return (
<Draggable
onDrag={handleDragMove}
onStart={handleDragStart}
onStop={handleDragStop}
defaultPosition={position}
position={isFloating ? position : { x: 0, y: 0 }}
bounds="parent"
handle=".draggable"
cancel=".button"
disabled={noFloatingPlayer}
>
<div
className={classnames([CONTENT_VIEWER_CLASS], {
[FLOATING_PLAYER_CLASS]: isFloating,
'content__viewer--inline': !isFloating,
'content__viewer--secondary': isComment,
'content__viewer--theater-mode': theaterMode && mainFilePlaying && !isCurrentClaimLive && !isMobile,
'content__viewer--disable-click': wasDragging,
'content__viewer--mobile': isMobile && !isLandscapeRotated && !playingUriSource,
})}
style={
!isFloating && fileViewerRect
? {
width: fileViewerRect.width,
height: appDrawerOpen ? `${getMaxLandscapeHeight()}px` : fileViewerRect.height,
left: fileViewerRect.x,
top:
isMobile && !playingUriSource
? HEADER_HEIGHT_MOBILE
: fileViewerRect.windowOffset + fileViewerRect.top - HEADER_HEIGHT,
}
: {}
}
>
{uri && videoAspectRatio && fileViewerRect ? (
<PlayerGlobalStyles
videoAspectRatio={videoAspectRatio}
theaterMode={theaterMode}
appDrawerOpen={appDrawerOpen && !isLandscapeRotated && !isTabletLandscape}
initialPlayerHeight={initialPlayerHeight}
isFloating={isFloating}
fileViewerRect={fileViewerRect}
mainFilePlaying={mainFilePlaying}
isLandscapeRotated={isLandscapeRotated}
isTabletLandscape={isTabletLandscape}
/>
) : null}
<>
{(uri && videoAspectRatio) || collectionSidebarId ? (
<PlayerGlobalStyles
videoAspectRatio={videoAspectRatio}
theaterMode={theaterMode}
appDrawerOpen={appDrawerOpen && !isLandscapeRotated && !isTabletLandscape}
initialPlayerHeight={initialPlayerHeight}
isFloating={isFloating}
fileViewerRect={fileViewerRect || mainPlayerDimensions}
mainFilePlaying={mainFilePlaying}
isLandscapeRotated={isLandscapeRotated}
isTabletLandscape={isTabletLandscape}
/>
) : null}
<div className={classnames('content__wrapper', { 'content__wrapper--floating': isFloating })}>
{isFloating && (
<Button
title={__('Close')}
onClick={() => doSetPlayingUri({ uri: null })}
icon={ICONS.REMOVE}
button="primary"
className="content__floating-close"
/>
)}
{uri && isPlayable && (
<Draggable
onDrag={handleDragMove}
onStart={handleDragStart}
onStop={handleDragStop}
defaultPosition={position}
position={isFloating ? position : { x: 0, y: 0 }}
bounds="parent"
handle=".draggable"
cancel=".button"
disabled={noFloatingPlayer || forceDisable}
>
<div
className={classnames([CONTENT_VIEWER_CLASS], {
[FLOATING_PLAYER_CLASS]: isFloating,
'content__viewer--inline': !isFloating,
'content__viewer--secondary': isComment,
'content__viewer--theater-mode': theaterMode && mainFilePlaying && !isCurrentClaimLive && !isMobile,
'content__viewer--disable-click': wasDragging,
'content__viewer--mobile': isMobile && !isLandscapeRotated && !playingUriSource,
})}
style={
!isFloating && fileViewerRect
? {
width: fileViewerRect.width,
height: appDrawerOpen ? `${getMaxLandscapeHeight()}px` : fileViewerRect.height,
left: fileViewerRect.x,
top:
isMobile && !playingUriSource
? HEADER_HEIGHT_MOBILE
: fileViewerRect.windowOffset + fileViewerRect.top - HEADER_HEIGHT,
}
: {}
}
>
<div className={classnames('content__wrapper', { 'content__wrapper--floating': isFloating })}>
{isFloating && (
<Button
title={__('Close')}
onClick={() => {
if (hasClaimInQueue) {
doOpenModal(MODALS.CONFIRM, {
title: __('Close Player'),
subtitle: __('Are you sure you want to close the player and clear the current Queue?'),
onConfirm: (closeModal) => {
doClearPlayingUri();
doClearQueueList();
closeModal();
},
});
} else {
doClearPlayingUri();
}
}}
icon={ICONS.REMOVE}
button="primary"
className="content__floating-close"
/>
)}
{isReadyToPlay ? (
<FileRender className={classnames({ draggable: !isMobile })} uri={uri} />
) : collectionId && !canViewFile ? (
<div className="content__loading">
<AutoplayCountdown
nextRecommendedUri={nextListUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => doUriInitiatePlay({ uri, collectionId }, false, isFloating)}
doPrevious={() => {
setPlayNext(false);
setDoNavigate(true);
}}
onCanceled={() => setCountdownCanceled(true)}
skipPaid
/>
{isReadyToPlay && !isMature ? (
<FileRender className={classnames({ draggable: !isMobile })} uri={uri} />
) : isLoading ? (
<LoadingScreen status={__('Loading')} />
) : (
(!collectionId || !canViewFile || isMature) && (
<div className="content__loading">
<AutoplayCountdown
uri={uri}
nextRecommendedUri={nextListUri || firstCollectionItemUrl}
doNavigate={() => setDoNavigate(true)}
doReplay={() => doUriInitiatePlay({ uri, collection: { collectionId } }, false, isFloating)}
doPrevious={
!previousListUri
? undefined
: () => {
setPlayNext(false);
setDoNavigate(true);
}
}
onCanceled={() => setCountdownCanceled(true)}
skipPaid
skipMature
/>
</div>
)
)}
{isFloating && (
<div className={classnames('content__info', { draggable: !isMobile })}>
<div className="content-info__text">
<div className="claim-preview__title" title={title || uri}>
<Button
label={title || uri}
navigate={navigateUrl}
button="link"
className="content__floating-link"
/>
</div>
<UriIndicator link uri={uri} />
</div>
{playingCollection && collectionSidebarId !== collectionId && (
<PlaylistCard
id={collectionId}
uri={uri}
disableClickNavigation
doDisablePlayerDrag={setForceDisable}
isFloating
/>
)}
</div>
)}
</div>
) : (
<LoadingScreen status={__('Loading')} />
)}
{isFloating && (
<div className={classnames('content__info', { draggable: !isMobile })}>
<div className="claim-preview__title" title={title || uri}>
<Button label={title || uri} navigate={navigateUrl} button="link" className="content__floating-link" />
</div>
<UriIndicator link uri={uri} />
</div>
)}
</div>
</div>
</Draggable>
</div>
</Draggable>
)}
</>
);
}
@ -493,6 +557,8 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
isTabletLandscape,
} = props;
const justChanged = React.useRef();
const isMobile = useIsMobile();
const isMobilePlayer = isMobile && !isFloating; // to avoid miniplayer -> file page only
@ -511,14 +577,30 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
// Handles video shrink + center on mobile view
// direct DOM manipulation due to performance for every scroll
React.useEffect(() => {
if (!isMobilePlayer || !mainFilePlaying || appDrawerOpen || isLandscapeRotated || isTabletLandscape) return;
if (!isMobilePlayer || !mainFilePlaying || isLandscapeRotated || isTabletLandscape) return;
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
if (viewer) viewer.style.height = `${heightForViewer}px`;
if (viewer) {
if (!appDrawerOpen && heightForViewer) viewer.style.height = `${heightForViewer}px`;
if (!appDrawerOpen) {
const htmlEl = document.querySelector('html');
if (htmlEl) htmlEl.scrollTop = 0;
}
justChanged.current = true;
}
if (appDrawerOpen) return;
function handleScroll() {
const rootEl = getRootEl();
if (justChanged.current) {
justChanged.current = false;
return;
}
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
const videoNode = document.querySelector('.vjs-tech');
const touchOverlay = document.querySelector('.vjs-touch-overlay');
@ -548,10 +630,6 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
window.addEventListener('scroll', handleScroll);
return () => {
// clear the added styles on unmount
const viewer = document.querySelector(`.${CONTENT_VIEWER_CLASS}`);
// $FlowFixMe
if (viewer) viewer.style.height = undefined;
const touchOverlay = document.querySelector('.vjs-touch-overlay');
if (touchOverlay) touchOverlay.removeAttribute('style');
@ -569,9 +647,12 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
]);
React.useEffect(() => {
if (appDrawerOpen && videoGreaterThanLandscape && isMobilePlayer) {
if (videoGreaterThanLandscape && isMobilePlayer) {
const videoNode = document.querySelector('.vjs-tech');
if (videoNode) videoNode.style.top = `${amountNeededToCenter}px`;
if (videoNode) {
const top = appDrawerOpen ? amountNeededToCenter : 0;
videoNode.style.top = `${top}px`;
}
}
if (isMobile && isFloating) {
@ -655,6 +736,17 @@ const PlayerGlobalStyles = (props: GlobalStylesProps) => {
: undefined,
...maxHeight,
},
'.playlist-card': {
maxHeight:
!isMobile && !theaterMode && mainFilePlaying
? `${heightForViewer}px`
: isMobile
? '100%'
: fileViewerRect
? `${fileViewerRect.height}px`
: undefined,
},
}}
/>
);

View file

@ -120,7 +120,7 @@ export default function FileRenderInitiator(props: Props) {
// Wrap this in useCallback because we need to use it to the view effect
// If we don't a new instance will be created for every render and react will think the dependencies have changed, which will add/remove the listener for every render
const viewFile = React.useCallback(() => {
const playingOptions = { uri, collectionId, pathname, source: undefined, commentId: undefined };
const playingOptions = { uri, collection: { collectionId }, pathname, source: undefined, commentId: undefined };
if (parentCommentId) {
playingOptions.source = 'comment';

View file

@ -1,10 +1,19 @@
import { connect } from 'react-redux';
import { doResolveUri } from 'redux/actions/claims';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { selectHasResolvedClaimForUri, selectThumbnailForUri } from 'redux/selectors/claims';
import CardMedia from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
});
const select = (state, props) => {
const { uri } = props;
export default connect(select, { doResolveUri })(CardMedia);
return {
hasResolvedClaim: uri ? selectHasResolvedClaimForUri(state, uri) : undefined,
thumbnailFromClaim: selectThumbnailForUri(state, uri),
};
};
const perform = {
doResolveUri,
};
export default connect(select, perform)(CardMedia);

View file

@ -9,16 +9,21 @@ type Props = {
fallback: ?string,
children?: Node,
className?: string,
small?: boolean,
};
const Thumb = (props: Props) => {
const { thumb, fallback, children, className } = props;
const { thumb, fallback, children, className, small } = props;
const thumbnailRef = React.useRef(null);
useLazyLoading(thumbnailRef, fallback || '');
return (
<div ref={thumbnailRef} data-background-image={thumb} className={classnames('media__thumb', className)}>
<div
ref={thumbnailRef}
data-background-image={thumb}
className={classnames('media__thumb', { className, 'media__thumb--small': small })}
>
{children}
</div>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View file

@ -11,62 +11,76 @@ import {
import { useIsMobile } from 'effects/use-screensize';
import { getImageProxyUrl, getThumbnailCdnUrl } from 'util/thumbnail';
import React from 'react';
import FreezeframeWrapper from './FreezeframeWrapper';
import Placeholder from './placeholder.png';
import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
import classnames from 'classnames';
import Thumb from './thumb';
import Thumb from './internal/thumb';
type Props = {
uri: string,
uri?: string,
tileLayout?: boolean,
thumbnail: ?string, // externally sourced image
children?: Node,
allowGifs: boolean,
claim: ?StreamClaim,
doResolveUri: (string) => void,
className?: string,
small?: boolean,
forcePlaceholder?: boolean,
// -- redux --
hasResolvedClaim: ?boolean, // undefined if uri is not given (irrelevant); boolean otherwise.
thumbnailFromClaim: ?string,
doResolveUri: (uri: string) => void,
};
function FileThumbnail(props: Props) {
const {
claim,
uri,
tileLayout,
doResolveUri,
thumbnail: rawThumbnail,
children,
allowGifs = false,
className,
small,
forcePlaceholder,
// -- redux --
hasResolvedClaim,
thumbnailFromClaim,
doResolveUri,
} = props;
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
const thumbnailFromClaim =
uri && claim && claim.value && claim.value.thumbnail ? claim.value.thumbnail.url : undefined;
const thumbnail = passedThumbnail || thumbnailFromClaim;
const hasResolvedClaim = claim !== undefined;
const isGif = thumbnail && thumbnail.endsWith('gif');
const isMobile = useIsMobile();
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
const thumbnail = passedThumbnail || thumbnailFromClaim;
const isGif = thumbnail && thumbnail.endsWith('gif');
React.useEffect(() => {
if (!hasResolvedClaim && uri && !passedThumbnail) {
if (hasResolvedClaim === false && uri && !passedThumbnail) {
doResolveUri(uri);
}
}, [hasResolvedClaim, uri, doResolveUri, passedThumbnail]);
}, [hasResolvedClaim, passedThumbnail, doResolveUri, uri]);
if (!allowGifs && isGif) {
const url = getImageProxyUrl(thumbnail);
return (
<FreezeframeWrapper src={url} className={classnames('media__thumb', className)}>
{children}
</FreezeframeWrapper>
url && (
<FreezeframeWrapper
small={small}
src={url}
className={classnames('media__thumb', className, {
'media__thumb--resolving': hasResolvedClaim === false,
'media__thumb--small': small,
})}
>
{children}
</FreezeframeWrapper>
)
);
}
const fallback = MISSING_THUMB_DEFAULT ? getThumbnailCdnUrl({ thumbnail: MISSING_THUMB_DEFAULT }) : undefined;
let url = thumbnail || (hasResolvedClaim ? Placeholder : '');
// @if TARGET='web'
let url = thumbnail || (hasResolvedClaim ? MISSING_THUMB_DEFAULT : '');
// Pass image urls through a compression proxy
if (thumbnail) {
if (isGif) {
@ -80,21 +94,22 @@ function FileThumbnail(props: Props) {
});
}
}
// @endif
const thumbnailUrl = url ? url.replace(/'/g, "\\'") : '';
if (hasResolvedClaim || thumbnailUrl) {
if (hasResolvedClaim || thumbnailUrl || (forcePlaceholder && !uri)) {
return (
<Thumb thumb={thumbnailUrl} fallback={fallback} className={className}>
<Thumb small={small} thumb={thumbnailUrl || MISSING_THUMB_DEFAULT} fallback={fallback} className={className}>
{children}
</Thumb>
);
}
return (
<div
className={classnames('media__thumb', className, {
'media__thumb--resolving': !hasResolvedClaim,
'media__thumb--resolving': hasResolvedClaim === false,
'media__thumb--small': small,
})}
>
{children}

View file

@ -1,21 +1,19 @@
import { connect } from 'react-redux';
import { doCollectionEdit } from 'redux/actions/collections';
import { makeSelectCollectionForIdHasClaimUrl } from 'redux/selectors/collections';
import { selectCollectionForIdHasClaimUrl } from 'redux/selectors/collections';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import FileWatchLaterLink from './view';
import { doToast } from 'redux/actions/notifications';
import { doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
const select = (state, props) => {
const { uri } = props;
return {
hasClaimInWatchLater: makeSelectCollectionForIdHasClaimUrl(COLLECTIONS_CONSTS.WATCH_LATER_ID, uri)(state),
hasClaimInWatchLater: selectCollectionForIdHasClaimUrl(state, COLLECTIONS_CONSTS.WATCH_LATER_ID, uri),
};
};
const perform = (dispatch) => ({
doToast: (props) => dispatch(doToast(props)),
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),
});
const perform = {
doPlaylistAddAndAllowPlaying,
};
export default connect(select, perform)(FileWatchLaterLink);

View file

@ -3,32 +3,49 @@ import * as ICONS from 'constants/icons';
import React, { useRef } from 'react';
import Button from 'component/button';
import useHover from 'effects/use-hover';
import { useHistory } from 'react-router';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import { formatLbryUrlForWeb } from 'util/url';
type Props = {
uri: string,
focusable: boolean,
hasClaimInWatchLater: boolean,
doToast: ({ message: string }) => void,
doCollectionEdit: (string, any) => void,
doPlaylistAddAndAllowPlaying: (params: {
uri: string,
collectionName: string,
collectionId: string,
push: (uri: string) => void,
}) => void,
};
function FileWatchLaterLink(props: Props) {
const { uri, hasClaimInWatchLater, doToast, doCollectionEdit, focusable = true } = props;
const { uri, hasClaimInWatchLater, focusable = true, doPlaylistAddAndAllowPlaying } = props;
const {
push,
location: { search },
} = useHistory();
const buttonRef = useRef();
let isHovering = useHover(buttonRef);
function handleWatchLater(e) {
e.preventDefault();
doToast({
message: hasClaimInWatchLater ? __('Item removed from Watch Later') : __('Item added to Watch Later'),
linkText: !hasClaimInWatchLater && __('See All'),
linkTarget: !hasClaimInWatchLater && '/list/watchlater',
});
doCollectionEdit(COLLECTIONS_CONSTS.WATCH_LATER_ID, {
uris: [uri],
remove: hasClaimInWatchLater,
type: 'playlist',
if (e) e.preventDefault();
const urlParams = new URLSearchParams(search);
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, COLLECTIONS_CONSTS.WATCH_LATER_ID);
doPlaylistAddAndAllowPlaying({
uri,
collectionName: COLLECTIONS_CONSTS.WATCH_LATER_NAME,
collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID,
push: (pushUri) =>
push({
pathname: formatLbryUrlForWeb(pushUri),
search: urlParams.toString(),
state: { collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID, forceAutoplay: true },
}),
});
}
@ -39,19 +56,18 @@ function FileWatchLaterLink(props: Props) {
const label = !hasClaimInWatchLater ? __('Watch Later') : __('Remove');
return (
<Button
ref={buttonRef}
requiresAuth={IS_WEB}
title={title}
label={label}
className="button--file-action"
icon={
(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) ||
(isHovering ? ICONS.COMPLETED : ICONS.TIME)
}
onClick={(e) => handleWatchLater(e)}
tabIndex={focusable ? 0 : -1}
/>
<div className="claim-preview__hover-actions second-item">
<Button
ref={buttonRef}
requiresAuth
title={title}
label={label}
className="button--file-action"
icon={(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) || ICONS.TIME}
onClick={(e) => handleWatchLater(e)}
tabIndex={focusable ? 0 : -1}
/>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import FormNewCollection from './view';
import { doPlaylistAddAndAllowPlaying } from 'redux/actions/content';
const perform = {
doPlaylistAddAndAllowPlaying,
};
export default connect(null, perform)(FormNewCollection);

View file

@ -0,0 +1,120 @@
// @flow
import React from 'react';
import type { ElementRef } from 'react';
import * as ICONS from 'constants/icons';
import * as KEYCODES from 'constants/keycodes';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import { FormField } from 'component/common/form';
import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url';
import Button from 'component/button';
type Props = {
uri: string,
onlyCreate?: boolean,
closeForm: (newCollectionName?: string) => void,
// -- redux --
doPlaylistAddAndAllowPlaying: (params: {
uri: string,
collectionName: string,
createNew: boolean,
push: (uri: string) => void,
}) => void,
};
function FormNewCollection(props: Props) {
const { uri, onlyCreate, closeForm, doPlaylistAddAndAllowPlaying } = props;
const {
push,
location: { search },
} = useHistory();
const buttonref: ElementRef<any> = React.useRef();
const newCollectionName = React.useRef('');
const [disabled, setDisabled] = React.useState(true);
function handleNameInput(e) {
const { value } = e.target;
newCollectionName.current = value;
setDisabled(value.length === 0);
}
function handleAddCollection() {
const name = newCollectionName.current;
const urlParams = new URLSearchParams(search);
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, COLLECTIONS_CONSTS.WATCH_LATER_ID);
doPlaylistAddAndAllowPlaying({
uri,
collectionName: name,
createNew: true,
push: (pushUri) =>
push({
pathname: formatLbryUrlForWeb(pushUri),
search: urlParams.toString(),
state: { collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID, forceAutoplay: true },
}),
});
closeForm(name);
}
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ENTER) {
e.preventDefault();
buttonref.current.click();
}
}
function onTextareaFocus() {
window.addEventListener('keydown', altEnterListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', altEnterListener);
}
function handleClearNew() {
closeForm();
}
return (
<FormField
autoFocus
type="text"
name="new_collection"
label={__('New Playlist Title')}
placeholder={__(COLLECTIONS_CONSTS.PLACEHOLDER)}
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
inputButton={
<>
<Button
button="alt"
icon={ICONS.COMPLETED}
title={__('Confirm')}
className="button-toggle"
disabled={disabled}
onClick={handleAddCollection}
ref={buttonref}
/>
{!onlyCreate && (
<Button
button="alt"
className="button-toggle"
icon={ICONS.REMOVE}
title={__('Cancel')}
onClick={handleClearNew}
/>
)}
</>
}
onChange={handleNameInput}
/>
);
}
export default FormNewCollection;

View file

@ -11,6 +11,7 @@ import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
import FileRenderInitiator from 'component/fileRenderInitiator';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import * as ICONS from 'constants/icons';
import * as DRAWERS from 'constants/drawer_types';
import SwipeableDrawer from 'component/swipeableDrawer';
import DrawerExpandButton from 'component/swipeableDrawerExpand';
import LivestreamMenu from 'component/livestreamChatLayout/livestream-menu';
@ -104,6 +105,8 @@ export default function LivestreamLayout(props: Props) {
{isMobile && !isLandscapeRotated && !hideComments && (
<React.Suspense fallback={null}>
<SwipeableDrawer
startOpen
type={DRAWERS.CHAT}
title={
<ChatModeSelector
superChats={superChats}
@ -131,7 +134,7 @@ export default function LivestreamLayout(props: Props) {
/>
</SwipeableDrawer>
<DrawerExpandButton label={__('Open Live Chat')} />
<DrawerExpandButton icon={ICONS.CHAT} label={__('Open Live Chat')} type={DRAWERS.CHAT} />
</React.Suspense>
)}

View file

@ -0,0 +1,55 @@
import { connect } from 'react-redux';
import PlaylistCard from './view';
import { selectClaimForUri } from 'redux/selectors/claims';
import {
selectUrlsForCollectionId,
selectNameForCollectionId,
selectCollectionIsMine,
selectIsCollectionPrivateForId,
selectPublishedCollectionChannelNameForId,
selectIndexForUrlInCollection,
selectCollectionLengthForId,
selectCollectionIsEmptyForId,
selectCollectionForId,
} from 'redux/selectors/collections';
import { selectPlayingUri } from 'redux/selectors/content';
import { doCollectionEdit, doClearQueueList } from 'redux/actions/collections';
import { doClearPlayingCollection } from 'redux/actions/content';
import { doOpenModal } from 'redux/actions/app';
const select = (state, props) => {
const { id: collectionId } = props;
const {
uri: playingUri,
collection: { collectionId: playingCollectionId },
} = selectPlayingUri(state);
const playingCurrentPlaylist = collectionId === playingCollectionId;
const { permanent_url: playingItemUrl } = playingCurrentPlaylist ? selectClaimForUri(state, playingUri) || {} : {};
const playingItemIndex = selectIndexForUrlInCollection(state, playingItemUrl, playingCollectionId, true);
return {
playingItemUrl,
playingCurrentPlaylist,
collectionUrls: selectUrlsForCollectionId(state, collectionId),
collectionName: selectNameForCollectionId(state, collectionId),
isMyCollection: selectCollectionIsMine(state, collectionId),
isPrivateCollection: selectIsCollectionPrivateForId(state, collectionId),
publishedCollectionName: selectPublishedCollectionChannelNameForId(state, collectionId),
playingItemIndex: playingItemIndex !== null ? playingItemIndex + 1 : 0,
collectionLength: selectCollectionLengthForId(state, collectionId),
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
hasCollectionById: collectionId && Boolean(selectCollectionForId(state, collectionId)),
playingCollectionId,
};
};
const perform = {
doCollectionEdit,
doClearPlayingCollection,
doClearQueueList,
doOpenModal,
};
export default connect(select, perform)(PlaylistCard);

View file

@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import LoopButton from './view';
import { selectListIsLoopedForId } from 'redux/selectors/content';
import { doToggleLoopList } from 'redux/actions/content';
const select = (state, props) => {
const { id: collectionId } = props;
return {
loop: selectListIsLoopedForId(state, collectionId),
};
};
const perform = {
doToggleLoopList,
};
export default connect(select, perform)(LoopButton);

View file

@ -0,0 +1,28 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Button from 'component/button';
type Props = {
id: string,
// -- redux --
loop: boolean,
doToggleLoopList: (params: { collectionId: string }) => void,
};
const LoopButton = (props: Props) => {
const { id, loop, doToggleLoopList } = props;
return (
<Button
button="alt"
className="button--alt-no-style button-toggle"
title={__('Loop')}
icon={ICONS.REPEAT}
iconColor={loop ? 'blue' : undefined}
onClick={() => doToggleLoopList({ collectionId: id })}
/>
);
};
export default LoopButton;

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