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 // eslint-disable-next-line import/prefer-default-export
export function doFetchCostInfoForUri(uri: string) { export function doFetchCostInfoForUri(uri: string) {
return (dispatch: Dispatch, getState: GetState) => { return async (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const claim = selectClaimForUri(state, uri); const claim = selectClaimForUri(state, uri);
if (!claim) return; if (!claim) return;
function resolve(costInfo) {
dispatch({
type: ACTIONS.FETCH_COST_INFO_COMPLETED,
data: {
uri,
costInfo,
},
});
}
const fee = claim.value ? claim.value.fee : undefined; const fee = claim.value ? claim.value.fee : undefined;
let costInfo;
if (fee === undefined) { if (fee === undefined) {
resolve({ cost: 0, includesData: true }); costInfo = { cost: 0, includesData: true };
} else if (fee.currency === 'LBC') { } else if (fee.currency === 'LBC') {
resolve({ cost: fee.amount, includesData: true }); costInfo = { cost: fee.amount, includesData: true };
} else { } else {
Lbryio.getExchangeRates().then(({ LBC_USD }) => { await Lbryio.getExchangeRates().then(({ LBC_USD }) => {
resolve({ cost: fee.amount / LBC_USD, includesData: true }); 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, id: string,
items: Array<?string>, items: Array<?string>,
name: string, name: string,
description?: string,
thumbnail?: {
url?: string,
},
type: string, type: string,
createdAt?: ?number,
updatedAt: number, updatedAt: number,
totalItems?: number, totalItems?: number,
itemCount?: number,
editsCleared?: boolean,
sourceId?: string, // if copied, claimId of original collection sourceId?: string, // if copied, claimId of original collection
}; };
@ -17,11 +24,25 @@ declare type CollectionState = {
saved: Array<string>, saved: Array<string>,
isResolvingCollectionById: { [string]: boolean }, isResolvingCollectionById: { [string]: boolean },
error?: string | null, error?: string | null,
queue: Collection,
}; };
declare type CollectionGroup = { declare type CollectionGroup = {
[string]: Collection, [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 = { declare type CollectionEditParams = {
uris?: Array<string>, uris?: Array<string>,
@ -30,4 +51,13 @@ declare type CollectionEditParams = {
order?: { from: number, to: number }, order?: { from: number, to: number },
type?: string, type?: string,
name?: 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 = { declare type ContentState = {
primaryUri: ?string, primaryUri: ?string,
playingUri: { uri?: string }, playingUri: {
uri?: string,
collection: PlayingCollection,
},
positions: { [string]: { [string]: number } }, // claimId: { outpoint: position } positions: { [string]: { [string]: number } }, // claimId: { outpoint: position }
history: Array<WatchHistory>, history: Array<WatchHistory>,
recommendationId: { [string]: string }, // claimId: recommendationId recommendationId: { [string]: string }, // claimId: recommendationId
recommendationParentId: { [string]: string }, // claimId: referrerId recommendationParentId: { [string]: string }, // claimId: referrerId
recommendationUrls: { [string]: Array<string> }, // claimId: [lbryUrls...] recommendationUrls: { [string]: Array<string> }, // claimId: [lbryUrls...]
recommendationClicks: { [string]: Array<number> }, // "claimId": [clicked indices...] 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. lastViewedAnnouncement: ?string, // undefined = not seen in wallet.
recsysEntries: { [ClaimId]: RecsysEntry }, // Persistent shadow copy. The main one resides in RecSys. recsysEntries: { [ClaimId]: RecsysEntry }, // Persistent shadow copy. The main one resides in RecSys.
}; };
@ -29,6 +26,12 @@ declare type PlayingUri = {
primaryUri?: string, primaryUri?: string,
pathname?: string, pathname?: string,
commentId?: string, commentId?: string,
collectionId?: ?string, collection: PlayingCollection,
source?: string, source?: string,
}; };
declare type PlayingCollection = {
collectionId?: ?string,
loop?: ?boolean,
shuffle?: ?{ newUrls: Array<string> },
};

View file

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

View file

@ -26,9 +26,24 @@
"Tags": "Tags", "Tags": "Tags",
"Share": "Share", "Share": "Share",
"Play": "Play", "Play": "Play",
"Play All": "Play All",
"Start Playing": "Start Playing",
"Shuffle Play": "Shuffle Play", "Shuffle Play": "Shuffle Play",
"Shuffle": "Shuffle", "Shuffle": "Shuffle",
"Play in Shuffle mode": "Play in Shuffle mode",
"Loop": "Loop", "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 Content": "Report Content", "Report Content": "Report Content",
"Report comment": "Report comment", "Report comment": "Report comment",
@ -1173,6 +1188,7 @@
"Paid": "Paid", "Paid": "Paid",
"Start at": "Start at", "Start at": "Start at",
"Include List ID": "Include List ID", "Include List ID": "Include List ID",
"Include Playlist ID": "Include Playlist ID",
"Links": "Links", "Links": "Links",
"Download Link": "Download Link", "Download Link": "Download Link",
"Mature content is not supported.": "Mature content is not supported.", "Mature content is not supported.": "Mature content is not supported.",
@ -1775,6 +1791,8 @@
"How does this work?": "How does this work?", "How does this work?": "How does this work?",
"Introducing Lists": "Introducing Lists", "Introducing Lists": "Introducing Lists",
"You have no lists yet. Better start hoarding!": "You have no lists yet. Better start hoarding!", "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", "Update your livestream": "Update your livestream",
"Prepare an upcoming livestream": "Prepare an upcoming livestream", "Prepare an upcoming livestream": "Prepare an upcoming livestream",
"Edit your post": "Edit your post", "Edit your post": "Edit your post",
@ -1958,15 +1976,17 @@
"Lists": "Lists", "Lists": "Lists",
"Watch Later": "Watch Later", "Watch Later": "Watch Later",
"Favorites": "Favorites", "Favorites": "Favorites",
"New List": "New List",
"New List Title": "New List Title",
"Add to Lists": "Add to Lists", "Add to Lists": "Add to Lists",
"Add to Playlist": "Add to Playlist",
"Playlists": "Playlists", "Playlists": "Playlists",
"Edit List": "Edit List", "Edit List": "Edit List",
"Edit Playlist": "Edit Playlist",
"Delete List": "Delete List", "Delete List": "Delete List",
"Delete Playlist": "Delete Playlist",
"Private": "Private", "Private": "Private",
"Public": "Public", "Public": "Public",
"View List": "View List", "View List": "View List",
"View Playlist": "View Playlist",
"Publish List": "Publish List", "Publish List": "Publish List",
"Info": "Info", "Info": "Info",
"Publishes": "Publishes", "Publishes": "Publishes",
@ -1979,7 +1999,7 @@
"Credits": "Credits", "Credits": "Credits",
"Cannot publish empty list": "Cannot publish empty list", "Cannot publish empty list": "Cannot publish empty list",
"MyAwesomeList": "MyAwesomeList", "MyAwesomeList": "MyAwesomeList",
"My Awesome List": "My Awesome List", "My Awesome Playlist": "My Awesome Playlist",
"This list has no items.": "This list has no items.", "This list has no items.": "This list has no items.",
"1 item": "1 item", "1 item": "1 item",
"%collectionCount% items": "%collectionCount% items", "%collectionCount% items": "%collectionCount% items",
@ -1989,6 +2009,7 @@
"URL Selected": "URL Selected", "URL Selected": "URL Selected",
"Keep": "Keep", "Keep": "Keep",
"Add this claim to a list": "Add this claim to a list", "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", "List is Empty": "List is Empty",
"Confirm List Unpublish": "Confirm List Unpublish", "Confirm List Unpublish": "Confirm List Unpublish",
"This will permanently delete the list.": "This will permanently delete the list.", "This will permanently delete the list.": "This will permanently delete the list.",
@ -2003,8 +2024,8 @@
"Remove from Watch Later": "Remove from Watch Later", "Remove from Watch Later": "Remove from Watch Later",
"Add to Watch Later": "Add to Watch Later", "Add to Watch Later": "Add to Watch Later",
"Added": "Added", "Added": "Added",
"Item removed from %name%": "Item removed from %name%", "Item removed from %playlist_name%": "Item removed from %playlist_name%",
"Item added to %name%": "Item added to %name%", "Item added to %playlist_name%": "Item added to %playlist_name%",
"Item added to Watch Later": "Item added to Watch Later", "Item added to Watch Later": "Item added to Watch Later",
"Item removed from Watch Later": "Item removed from Watch Later", "Item removed from Watch Later": "Item removed from Watch Later",
"Item added to Favorites": "Item added to Favorites", "Item added to Favorites": "Item added to Favorites",
@ -2135,7 +2156,6 @@
"Sending...": "Sending...", "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", "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%", "Choose %asset%": "Choose %asset%",
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
"filtered": "filtered", "filtered": "filtered",
"Failed to synchronize settings. Wait a while before retrying.": "Failed to synchronize settings. Wait a while before retrying.", "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.", "You are offline. Check your internet connection.": "You are offline. Check your internet connection.",
@ -2307,5 +2327,19 @@
"Latest Content Link": "Latest Content Link", "Latest Content Link": "Latest Content Link",
"Current Livestream Link": "Current Livestream Link", "Current Livestream Link": "Current Livestream Link",
"Back to Odysee Premium": "Back to Odysee Premium", "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--" "--end--": "--end--"
} }

View file

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

View file

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

View file

@ -36,6 +36,8 @@ type Props = {
pathname: string, pathname: string,
emailVerified: boolean, emailVerified: boolean,
requiresAuth: ?boolean, requiresAuth: ?boolean,
requiresChannel: ?boolean,
hasChannels: boolean,
myref: any, myref: any,
dispatch: any, dispatch: any,
'aria-label'?: string, 'aria-label'?: string,
@ -69,6 +71,8 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
activeClass, activeClass,
emailVerified, emailVerified,
requiresAuth, requiresAuth,
requiresChannel,
hasChannels,
myref, myref,
dispatch, // <button> doesn't know what to do with dispatch dispatch, // <button> doesn't know what to do with dispatch
pathname, pathname,
@ -195,8 +199,12 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
} }
} }
if (requiresAuth && !emailVerified) { if ((requiresAuth && !emailVerified) || (requiresChannel && !hasChannels)) {
let redirectUrl = `/$/${PAGES.AUTH}?redirect=${pathname}`; // 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) { if (authSrc) {
redirectUrl += `&src=${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 { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import Card from 'component/common/card'; import Card from 'component/common/card';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import * as PUBLISH from 'constants/publish';
import analytics from 'analytics'; import analytics from 'analytics';
import LbcSymbol from 'component/common/lbc-symbol'; import LbcSymbol from 'component/common/lbc-symbol';
import SUPPORTED_LANGUAGES from 'constants/supported_languages'; 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 NEKODEV = false; // Temporary flag to hide unfinished progress
const LANG_NONE = 'none';
const MAX_TAG_SELECT = 5; const MAX_TAG_SELECT = 5;
type Props = { type Props = {
@ -189,14 +188,14 @@ function ChannelForm(props: Props) {
function handleLanguageChange(index, code) { function handleLanguageChange(index, code) {
let langs = [...languageParam]; let langs = [...languageParam];
if (index === 0) { if (index === 0) {
if (code === LANG_NONE) { if (code === PUBLISH.LANG_NONE) {
// clear all // clear all
langs = []; langs = [];
} else { } else {
langs[0] = code; langs[0] = code;
} }
} else { } else {
if (code === LANG_NONE || code === langs[0]) { if (code === PUBLISH.LANG_NONE || code === langs[0]) {
langs.splice(1, 1); langs.splice(1, 1);
} else { } else {
langs[index] = code; langs[index] = code;
@ -501,7 +500,7 @@ function ChannelForm(props: Props) {
value={primaryLanguage} value={primaryLanguage}
helper={__('Your main content language')} helper={__('Your main content language')}
> >
<option key={'pri-langNone'} value={LANG_NONE}> <option key={'pri-langNone'} value={PUBLISH.LANG_NONE}>
{__('None selected')} {__('None selected')}
</option> </option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => ( {sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (
@ -519,7 +518,7 @@ function ChannelForm(props: Props) {
disabled={!languageParam[0]} disabled={!languageParam[0]}
helper={__('Your other content language')} helper={__('Your other content language')}
> >
<option key={'sec-langNone'} value={LANG_NONE}> <option key={'sec-langNone'} value={PUBLISH.LANG_NONE}>
{__('None selected')} {__('None selected')}
</option> </option>
{sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => ( {sortLanguageMap(SUPPORTED_LANGUAGES).map(([langKey, langName]) => (

View file

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

View file

@ -4,7 +4,7 @@ import { parseURI } from 'util/lbryURI';
import { getImageProxyUrl } from 'util/thumbnail'; import { getImageProxyUrl } from 'util/thumbnail';
import classnames from 'classnames'; import classnames from 'classnames';
import Gerbil from './gerbil.png'; import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper'; import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import { AVATAR_DEFAULT } from 'config'; import { AVATAR_DEFAULT } from 'config';
import useGetUserMemberships from 'effects/use-get-user-memberships'; import useGetUserMemberships from 'effects/use-get-user-memberships';
@ -98,12 +98,19 @@ function ChannelThumbnail(props: Props) {
if (isGif && !allowGifs) { if (isGif && !allowGifs) {
const url = getImageProxyUrl(channelThumbnail); const url = getImageProxyUrl(channelThumbnail);
return ( return (
<FreezeframeWrapper url && (
src={url} <FreezeframeWrapper
className={classnames('channel-thumbnail', className, { 'channel-thumbnail--xxsmall': xxsmall })} src={url}
> className={classnames('channel-thumbnail', className, {
{showMemberBadge && <PremiumBadge {...badgeProps} />} 'channel-thumbnail--small': small,
</FreezeframeWrapper> '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 { doOpenModal } from 'redux/actions/app';
import CollectionAddButton from './view'; import CollectionAddButton from './view';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri } from 'redux/selectors/claims';
import { makeSelectClaimUrlInCollection } from 'redux/selectors/collections'; import { selectClaimSavedForUrl } from 'redux/selectors/collections';
const select = (state, props) => { const select = (state, props) => {
const { uri } = props; const { uri } = props;
const claim = selectClaimForUri(state, uri); const { permanent_url: permanentUrl, value } = selectClaimForUri(state, uri) || {};
const { stream_type: streamType } = value || {};
// $FlowFixMe
const { permanent_url: permanentUrl, value } = claim || {};
const streamType = (value && value.stream_type) || '';
return { return {
streamType, 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 MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import Button from 'component/button'; import FileActionButton from 'component/common/file-action-button';
import classnames from 'classnames';
import Tooltip from 'component/common/tooltip';
type Props = { type Props = {
uri: string, uri: string,
fileAction?: boolean,
type?: boolean,
// redux // redux
streamType: Claim, streamType: Claim,
isSaved: boolean, isSaved: boolean,
doOpenModal: (id: string, {}) => void, doOpenModal: (id: string, {}) => void,
}; };
export default function CollectionAddButton(props: Props) { function ClaimCollectionAddButton(props: Props) {
const { uri, fileAction, type = 'playlist', isSaved, streamType, doOpenModal } = props; const { uri, streamType, isSaved, doOpenModal } = props;
const isPlayable = streamType === 'video' || streamType === 'audio'; const isPlayable = streamType === 'video' || streamType === 'audio';
return !isPlayable ? null : ( if (!isPlayable) return null;
<Tooltip title={__('Add this claim to a list')} arrow={false}>
<Button return (
button={!fileAction ? 'alt' : undefined} <FileActionButton
className={classnames({ 'button--file-action': fileAction })} title={__('Add this video to a playlist')}
icon={fileAction ? (!isSaved ? ICONS.ADD : ICONS.STACK) : ICONS.LIBRARY} label={!isSaved ? __('Save') : __('Saved')}
iconSize={fileAction ? 16 : undefined} icon={!isSaved ? ICONS.PLAYLIST_ADD : ICONS.PLAYLIST_FILLED}
label={uri ? (!isSaved ? __('Save') : __('Saved')) : __('New List')} iconSize={20}
requiresAuth requiresAuth
onClick={(e) => { onClick={() => doOpenModal(MODALS.COLLECTION_ADD, { uri })}
e.preventDefault(); />
e.stopPropagation();
doOpenModal(MODALS.COLLECTION_ADD, { uri, type });
}}
/>
</Tooltip>
); );
} }
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 useGetLastVisibleSlot from 'effects/use-get-last-visible-slot';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import ClaimPreviewTile from 'component/claimPreviewTile'; import ClaimPreviewTile from 'component/claimPreviewTile';
import Button from 'component/button';
import { useIsMobile } from 'effects/use-screensize';
const Draggable = React.lazy(() => const Draggable = React.lazy(() =>
// $FlowFixMe // $FlowFixMe
@ -61,7 +63,16 @@ type Props = {
unavailableUris?: Array<string>, unavailableUris?: Array<string>,
showMemberBadge?: boolean, showMemberBadge?: boolean,
inWatchHistory?: boolean, inWatchHistory?: boolean,
smallThumbnail?: boolean,
showIndexes?: boolean,
playItemsOnClick?: boolean,
disableClickNavigation?: boolean,
setActiveListItemRef?: any,
setListRef?: any,
onHidden: (string) => void, onHidden: (string) => void,
doDisablePlayerDrag?: (disable: boolean) => void,
restoreScrollPos?: () => void,
setHasActive?: (has: boolean) => void,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -103,12 +114,24 @@ export default function ClaimList(props: Props) {
unavailableUris, unavailableUris,
showMemberBadge, showMemberBadge,
inWatchHistory, inWatchHistory,
smallThumbnail,
showIndexes,
playItemsOnClick,
disableClickNavigation,
setActiveListItemRef,
setListRef,
onHidden, onHidden,
doDisablePlayerDrag,
restoreScrollPos,
setHasActive,
} = props; } = props;
const isMobile = useIsMobile();
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
const uriBuffer = React.useRef([]); const uriBuffer = React.useRef([]);
const currentActiveItem = React.useRef();
// Resolve the index for injectedItem, if provided; else injectedIndex will be 'undefined'. // Resolve the index for injectedItem, if provided; else injectedIndex will be 'undefined'.
const listRef = React.useRef(); const listRef = React.useRef();
const findLastVisibleSlot = injectedItem && injectedItem.node && injectedItem.index === undefined; const findLastVisibleSlot = injectedItem && injectedItem.node && injectedItem.index === undefined;
@ -201,9 +224,15 @@ export default function ClaimList(props: Props) {
swipeLayout={swipeLayout} swipeLayout={swipeLayout}
showEdit={showEdit} showEdit={showEdit}
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps} dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
wrapperElement={draggableProvided ? 'div' : undefined}
unavailableUris={unavailableUris} unavailableUris={unavailableUris}
showMemberBadge={showMemberBadge} showMemberBadge={showMemberBadge}
inWatchHistory={inWatchHistory} inWatchHistory={inWatchHistory}
smallThumbnail={smallThumbnail}
showIndexes={showIndexes}
playItemsOnClick={playItemsOnClick}
disableClickNavigation={disableClickNavigation}
doDisablePlayerDrag={doDisablePlayerDrag}
/> />
); );
@ -223,6 +252,40 @@ export default function ClaimList(props: Props) {
return null; 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 ? ( return tileLayout && !header ? (
<> <>
<section ref={listRef} className={classnames('claim-grid', { 'swipe-list': swipeLayout })}> <section ref={listRef} className={classnames('claim-grid', { 'swipe-list': swipeLayout })}>
@ -269,11 +332,7 @@ export default function ClaimList(props: Props) {
)} )}
</> </>
) : ( ) : (
<section <section className={classnames('claim-list', { 'claim-list--no-margin': showIndexes })}>
className={classnames('claim-list', {
'claim-list--small': type === 'small',
})}
>
{header !== false && ( {header !== false && (
<React.Fragment> <React.Fragment>
{header && ( {header && (
@ -311,7 +370,7 @@ export default function ClaimList(props: Props) {
'swipe-list': swipeLayout, 'swipe-list': swipeLayout,
})} })}
{...(droppableProvided && droppableProvided.droppableProps)} {...(droppableProvided && droppableProvided.droppableProps)}
ref={droppableProvided ? droppableProvided.innerRef : listRef} ref={listRefCb}
> >
{droppableProvided ? ( {droppableProvided ? (
<> <>
@ -326,13 +385,46 @@ export default function ClaimList(props: Props) {
transform = transform.replace(/\(.+,/, '(0,'); 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 = { const style = {
...draggableProvided.draggableProps.style, ...draggableProvided.draggableProps.style,
transform, 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 ( 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 */} {/* https://github.com/atlassian/react-beautiful-dnd/issues/1756 */}
<div style={{ display: 'none' }} {...draggableProvided.dragHandleProps} /> <div style={{ display: 'none' }} {...draggableProvided.dragHandleProps} />
{getClaimPreview(uri, index, draggableProvided)} {getClaimPreview(uri, index, draggableProvided)}
@ -355,6 +447,15 @@ export default function ClaimList(props: Props) {
</ul> </ul>
)} )}
{restoreScrollPos && (
<Button
button="secondary"
className="claim-list__scroll-to-recent"
label={__('Scroll to Playing')}
onClick={restoreScrollPos}
/>
)}
{!timedOut && urisLength === 0 && !loading && !noEmpty && ( {!timedOut && urisLength === 0 && !loading && !noEmpty && (
<div className="empty empty--centered">{empty || noResultMsg}</div> <div className="empty empty--centered">{empty || noResultMsg}</div>
)} )}

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import {
selectGeoRestrictionForUri, selectGeoRestrictionForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info'; 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 { doResolveUri } from 'redux/actions/claims';
import { doFileGet } from 'redux/actions/file'; import { doFileGet } from 'redux/actions/file';
@ -22,7 +22,7 @@ import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { isClaimNsfw, isStreamPlaceholderClaim } from 'util/claim'; import { isClaimNsfw, isStreamPlaceholderClaim } from 'util/claim';
import ClaimPreview from './view'; import ClaimPreview from './view';
import formatMediaDuration from 'util/formatMediaDuration'; import formatMediaDuration from 'util/formatMediaDuration';
import { doClearContentHistoryUri } from 'redux/actions/content'; import { doClearContentHistoryUri, doUriInitiatePlay } from 'redux/actions/content';
const select = (state, props) => { const select = (state, props) => {
const claim = props.uri && selectClaimForUri(state, props.uri); const claim = props.uri && selectClaimForUri(state, props.uri);
@ -50,7 +50,7 @@ const select = (state, props) => {
isLivestream, isLivestream,
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri), isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined, livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state), isCollectionMine: selectCollectionIsMine(state, props.collectionId),
lang: selectLanguage(state), lang: selectLanguage(state),
}; };
}; };
@ -59,6 +59,8 @@ const perform = (dispatch) => ({
resolveUri: (uri) => dispatch(doResolveUri(uri)), resolveUri: (uri) => dispatch(doResolveUri(uri)),
getFile: (uri) => dispatch(doFileGet(uri, false)), getFile: (uri) => dispatch(doFileGet(uri, false)),
doClearContentHistoryUri: (uri) => dispatch(doClearContentHistoryUri(uri)), doClearContentHistoryUri: (uri) => dispatch(doClearContentHistoryUri(uri)),
doUriInitiatePlay: (playingOptions, isPlayable, isFloating) =>
dispatch(doUriInitiatePlay(playingOptions, isPlayable, isFloating)),
}); });
export default connect(select, perform)(ClaimPreview); 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 classnames from 'classnames';
import React from 'react'; import React from 'react';
import Empty from 'component/common/empty'; import Empty from 'component/common/empty';
import ButtonRemoveFromCollection from './buttonRemoveFromCollection';
type Props = { type Props = {
uri?: string,
collectionId?: ?string,
isChannel: boolean, isChannel: boolean,
type: string, type: string,
message: string, message: string,
}; };
function ClaimPreviewHidden(props: Props) { function ClaimPreviewHidden(props: Props) {
const { isChannel, type, message } = props; const { uri, collectionId, isChannel, type, message } = props;
return ( return (
<li <li
className={classnames('claim-preview__wrapper', { className={classnames('claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline', 'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': type === 'inline', 'claim-preview__wrapper--inline': type === 'inline',
'claim-preview__wrapper--small': type === 'small',
})} })}
> >
<div <div
@ -23,7 +28,14 @@ function ClaimPreviewHidden(props: Props) {
'claim-preview--large': type === 'large', '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} /> <Empty text={message} />
</div> </div>
</li> </li>

View file

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

View file

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

View file

@ -6,7 +6,9 @@ import {
selectDateForUri, selectDateForUri,
selectGeoRestrictionForUri, selectGeoRestrictionForUri,
selectClaimIsMine, selectClaimIsMine,
selectCanonicalUrlForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { selectUrlsForCollectionId } from 'redux/selectors/collections';
import { doFileGet } from 'redux/actions/file'; import { doFileGet } from 'redux/actions/file';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { selectViewCountForUri, selectBanStateForUri } from 'lbryinc'; 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 media = claim && claim.value && (claim.value.video || claim.value.audio);
const mediaDuration = media && media.duration && formatMediaDuration(media.duration, { screenReader: true }); const mediaDuration = media && media.duration && formatMediaDuration(media.duration, { screenReader: true });
const isLivestream = isStreamPlaceholderClaim(claim); 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 { return {
claim, claim,
@ -38,6 +44,7 @@ const select = (state, props) => {
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri), isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined, livestreamViewerCount: isLivestream && claim ? selectViewersForId(state, claim.claim_id) : undefined,
viewCount: selectViewCountForUri(state, props.uri), 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 { formatClaimPreviewTitle } from 'util/formatAriaLabel';
import { parseURI } from 'util/lbryURI'; import { parseURI } from 'util/lbryURI';
import PreviewOverlayProperties from 'component/previewOverlayProperties'; import PreviewOverlayProperties from 'component/previewOverlayProperties';
import FileDownloadLink from 'component/fileDownloadLink';
import FileHideRecommendation from 'component/fileHideRecommendation'; import FileHideRecommendation from 'component/fileHideRecommendation';
import FileWatchLaterLink from 'component/fileWatchLaterLink'; import FileWatchLaterLink from 'component/fileWatchLaterLink';
import ButtonAddToQueue from 'component/buttonAddToQueue';
import ClaimRepostAuthor from 'component/claimRepostAuthor'; import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList'; import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay'; import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
@ -59,6 +59,7 @@ type Props = {
swipeLayout: boolean, swipeLayout: boolean,
onHidden?: (string) => void, onHidden?: (string) => void,
pulse?: boolean, pulse?: boolean,
collectionFirstUrl: ?string,
}; };
// preview image cards used in related video functionality, channel overview page and homepage // preview image cards used in related video functionality, channel overview page and homepage
@ -94,6 +95,7 @@ function ClaimPreviewTile(props: Props) {
swipeLayout = false, swipeLayout = false,
onHidden, onHidden,
pulse, pulse,
collectionFirstUrl,
} = props; } = props;
const isRepost = claim && claim.repost_channel_url; const isRepost = claim && claim.repost_channel_url;
const isCollection = claim && claim.value_type === 'collection'; const isCollection = claim && claim.value_type === 'collection';
@ -111,7 +113,7 @@ function ClaimPreviewTile(props: Props) {
const collectionClaimId = isCollection && claim && claim.claim_id; const collectionClaimId = isCollection && claim && claim.claim_id;
const shouldFetch = claim === undefined; const shouldFetch = claim === undefined;
const thumbnailUrl = useGetThumbnail(uri, claim, streamingUrl, getFile, placeholder) || thumbnail; 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 repostedContentUri = claim && (claim.reposted_claim ? claim.reposted_claim.permanent_url : claim.permanent_url);
const listId = collectionId || collectionClaimId; const listId = collectionId || collectionClaimId;
const navigateUrl = const navigateUrl =
@ -254,32 +256,30 @@ function ClaimPreviewTile(props: Props) {
<FileThumbnail thumbnail={thumbnailUrl} allowGifs tileLayout> <FileThumbnail thumbnail={thumbnailUrl} allowGifs tileLayout>
{!isChannel && ( {!isChannel && (
<React.Fragment> <React.Fragment>
<div className="claim-preview__hover-actions"> {((fypId && isStream) || isPlayable) && (
{isPlayable && <FileWatchLaterLink focusable={false} uri={repostedContentUri} />} <div className="claim-preview__hover-actions-grid">
</div> {fypId && isStream && (
{fypId && ( <div className="claim-preview__hover-actions">
<div className="claim-preview__hover-actions"> <FileHideRecommendation focusable={false} uri={repostedContentUri} />
{isStream && <FileHideRecommendation focusable={false} uri={repostedContentUri} />} </div>
)}
{isPlayable && (
<>
<FileWatchLaterLink focusable={false} uri={repostedContentUri} />
<ButtonAddToQueue focusable={false} uri={repostedContentUri} />
</>
)}
</div> </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"> <div className="claim-preview__file-property-overlay">
<PreviewOverlayProperties uri={uri} properties={liveProperty || properties} /> <PreviewOverlayProperties uri={uri} properties={liveProperty || properties} />
</div> </div>
<ClaimPreviewProgress uri={uri} /> <ClaimPreviewProgress uri={uri} />
</React.Fragment> </React.Fragment>
)} )}
{isCollection && ( {isCollection && <CollectionPreviewOverlay collectionId={listId} />}
<React.Fragment>
<div className="claim-preview__collection-wrapper">
<CollectionPreviewOverlay collectionId={listId} uri={uri} />
</div>
</React.Fragment>
)}
</FileThumbnail> </FileThumbnail>
</NavLink> </NavLink>
<div className="claim-tile__header"> <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 MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import FileActionButton from 'component/common/file-action-button';
import Button from 'component/button';
import Tooltip from 'component/common/tooltip';
type Props = { type Props = {
uri: string, uri: string,
@ -19,6 +17,8 @@ type Props = {
export default function ClaimSupportButton(props: Props) { export default function ClaimSupportButton(props: Props) {
const { uri, fileAction, isRepost, disableSupport, doOpenModal, preferredCurrency } = props; const { uri, fileAction, isRepost, disableSupport, doOpenModal, preferredCurrency } = props;
if (disableSupport) return null;
const currencyToUse = preferredCurrency; const currencyToUse = preferredCurrency;
const iconToUse = { const iconToUse = {
@ -32,17 +32,14 @@ export default function ClaimSupportButton(props: Props) {
}, },
}; };
return disableSupport ? null : ( return (
<Tooltip title={__('Support this claim')} arrow={false}> <FileActionButton
<Button title={__('Support this content')}
button={!fileAction ? 'alt' : undefined} label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')}
className={classnames('support-claim-button', { 'button--file-action': fileAction })} icon={iconToUse[currencyToUse].icon}
icon={iconToUse[currencyToUse].icon} iconSize={iconToUse[currencyToUse].iconSize}
iconSize={iconToUse[currencyToUse].iconSize} onClick={() => doOpenModal(MODALS.SEND_TIP, { uri, isSupport: true })}
label={isRepost ? __('Support Repost') : __('Support --[button to support a claim]--')} noStyle={!fileAction}
requiresAuth />
onClick={() => doOpenModal(MODALS.SEND_TIP, { uri, isSupport: true })}
/>
</Tooltip>
); );
} }

View file

@ -16,7 +16,7 @@ function ClaimType(props: Props) {
const size = small ? COL.ICON_SIZE : undefined; const size = small ? COL.ICON_SIZE : undefined;
if (claimType === 'collection') { if (claimType === 'collection') {
return <Icon size={size} icon={ICONS.STACK} />; return <Icon size={size} icon={ICONS.PLAYLIST} />;
} else if (claimType === 'channel') { } else if (claimType === 'channel') {
return <Icon size={size} icon={ICONS.CHANNEL} />; return <Icon size={size} icon={ICONS.CHANNEL} />;
} else if (claimType === 'repost') { } 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 { connect } from 'react-redux';
import { doCollectionEdit } from 'redux/actions/collections'; import { doCollectionEdit } from 'redux/actions/collections';
import { makeSelectIndexForUrlInCollection, makeSelectUrlsForCollectionId } from 'redux/selectors/collections'; import { selectIndexForUrlInCollection, selectUrlsForCollectionId } from 'redux/selectors/collections';
import CollectionButtons from './view'; import CollectionButtons from './view';
const select = (state, props) => { const select = (state, props) => {
const { uri, collectionId } = props; const { uri, collectionId } = props;
return { return {
collectionIndex: makeSelectIndexForUrlInCollection(uri, collectionId, true)(state), collectionIndex: selectIndexForUrlInCollection(state, uri, collectionId, true),
collectionUris: makeSelectUrlsForCollectionId(collectionId)(state), collectionUris: selectUrlsForCollectionId(state, collectionId),
}; };
}; };

View file

@ -3,6 +3,7 @@ import * as ICONS from 'constants/icons';
import Button from 'component/button'; import Button from 'component/button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import React from 'react'; import React from 'react';
import classnames from 'classnames';
type Props = { type Props = {
collectionIndex?: number, collectionIndex?: number,
@ -10,26 +11,24 @@ type Props = {
dragHandleProps?: any, dragHandleProps?: any,
uri: string, uri: string,
editCollection: (CollectionEditParams) => void, editCollection: (CollectionEditParams) => void,
doDisablePlayerDrag?: (disable: boolean) => void,
}; };
export default function CollectionButtons(props: Props) { 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 [confirmDelete, setConfirmDelete] = React.useState(false);
const lastCollectionIndex = collectionUris ? collectionUris.length - 1 : 0; const lastCollectionIndex = collectionUris ? collectionUris.length - 1 : 0;
const collectionIndex = Number(foundIndex); 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 ( return (
<div <div
className="collection-preview__edit-buttons" className="collection-preview__edit-buttons"
@ -39,29 +38,45 @@ export default function CollectionButtons(props: Props) {
}} }}
> >
<div className="collection-preview__edit-group" {...dragHandleProps}> <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} /> <Icon icon={ICONS.DRAG} title={__('Drag')} size={20} />
</div> </div>
</div> </div>
<div className="collection-preview__edit-group"> <div className="collection-preview__edit-group">
{orderButton('', __('Move Top'), ICONS.UP_TOP, collectionIndex === 0, () => <OrderButton
editCollection({ order: { from: collectionIndex, to: 0 } }) 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, () => <OrderButton
editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } }) title={__('Move Bottom')}
)} icon={ICONS.DOWN_BOTTOM}
disabled={collectionIndex === lastCollectionIndex}
onClick={() => editCollection({ order: { from: collectionIndex, to: lastCollectionIndex } })}
/>
</div> </div>
<div className="collection-preview__edit-group"> <div className="collection-preview__edit-group">
{orderButton('', __('Move Up'), ICONS.UP, collectionIndex === 0, () => <OrderButton
editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } }) title={__('Move Up')}
)} icon={ICONS.UP}
disabled={collectionIndex === 0}
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex - 1 } })}
/>
{orderButton('', __('Move Down'), ICONS.DOWN, collectionIndex === lastCollectionIndex, () => <OrderButton
editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } }) title={__('Move Down')}
)} icon={ICONS.DOWN}
disabled={collectionIndex === lastCollectionIndex}
onClick={() => editCollection({ order: { from: collectionIndex, to: collectionIndex + 1 } })}
/>
</div> </div>
{!confirmDelete ? ( {!confirmDelete ? (
@ -82,11 +97,24 @@ export default function CollectionButtons(props: Props) {
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
/> />
{orderButton('button-collection-delete-confirm bottom-right', __('Remove'), ICONS.DELETE, false, () => <OrderButton
editCollection({ uris: [uri], remove: true }) className="button-collection-delete-confirm bottom-right"
)} title={__('Remove')}
icon={ICONS.DELETE}
onClick={() => editCollection({ uris: [uri], remove: true })}
/>
</div> </div>
)} )}
</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 { 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 { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content'; import { selectListShuffleForId } from 'redux/selectors/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content'; import { doToggleShuffleList } from 'redux/actions/content';
import CollectionMenuList from './view'; import CollectionMenuList from './view';
const select = (state, props) => { const select = (state, props) => {
const collectionId = props.collectionId; const collectionId = props.collectionId;
const shuffleList = selectListShuffle(state); const shuffleList = selectListShuffleForId(state, collectionId);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls; const playNextUri = shuffleList && shuffleList.newUrls[0];
const playNextUri = shuffle && shuffle[0];
return { return {
collectionName: makeSelectNameForCollectionId(props.collectionId)(state), collectionName: selectNameForCollectionId(state, collectionId),
playNextUri, playNextUri,
isBuiltin: selectIsCollectionBuiltInForId(state, collectionId),
publishedNotEdited: selectPublishedCollectionNotEditedForId(state, collectionId),
collectionEmpty: selectCollectionIsEmptyForId(state, collectionId),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)), doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToggleShuffleList: (collectionId) => { doToggleShuffleList: (params) => dispatch(doToggleShuffleList(params)),
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
},
}); });
export default connect(select, perform)(CollectionMenuList); 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 * as PAGES from 'constants/pages';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url'; import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import { PUBLISH_PAGE, EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
type Props = { type Props = {
inline?: boolean, inline?: boolean,
@ -15,11 +16,24 @@ type Props = {
collectionName?: string, collectionName?: string,
collectionId: string, collectionId: string,
playNextUri: 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) { 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 [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory(); const { push } = useHistory();
@ -47,45 +61,64 @@ function CollectionMenuList(props: Props) {
> >
<Icon size={20} icon={ICONS.MORE_VERTICAL} /> <Icon size={20} icon={ICONS.MORE_VERTICAL} />
</MenuButton> </MenuButton>
<MenuList className="menu__list"> <MenuList className="menu__list">
{collectionId && collectionName && ( {collectionId && collectionName && (
<> <>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}> <MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}> <a className="menu__link" href={`/$/${PAGES.PLAYLIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} /> <Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')} {__('Open')}
</a> </a>
</MenuItem> </MenuItem>
<MenuItem {!collectionEmpty && (
className="comment__menu-option" <MenuItem
onSelect={() => { className="comment__menu-option"
doToggleShuffleList(collectionId); onSelect={() => {
setDoShuffle(true); doToggleShuffleList({ collectionId });
}} setDoShuffle(true);
> }}
<div className="menu__link"> >
<Icon aria-hidden icon={ICONS.SHUFFLE} /> <div className="menu__link">
{__('Shuffle Play')} <Icon aria-hidden icon={ICONS.SHUFFLE} />
</div> {__('Shuffle Play')}
</MenuItem> </div>
<MenuItem </MenuItem>
className="comment__menu-option" )}
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
> {!isBuiltin && (
<div className="menu__link"> <>
<Icon aria-hidden icon={ICONS.PUBLISH} /> {!collectionEmpty && (
{__('Publish List')} <MenuItem
</div> className="comment__menu-option"
</MenuItem> onSelect={() => push(`/$/${PAGES.PLAYLIST}/${collectionId}?${PAGE_VIEW_QUERY}=${PUBLISH_PAGE}`)}
<MenuItem >
className="comment__menu-option" <div className="menu__link">
onSelect={() => doOpenModal(MODALS.COLLECTION_DELETE, { collectionId })} <Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
> {publishedNotEdited ? __('Update') : __('Publish')}
<div className="menu__link"> </div>
<Icon aria-hidden icon={ICONS.DELETE} /> </MenuItem>
{__('Delete List')} )}
</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> </MenuList>

View file

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

View file

@ -4,43 +4,42 @@ import { withRouter } from 'react-router-dom';
import FileThumbnail from 'component/fileThumbnail'; import FileThumbnail from 'component/fileThumbnail';
type Props = { type Props = {
uri: string,
collectionId: string, collectionId: string,
collectionName: string, // redux
collectionCount: number,
editedCollection?: Collection,
pendingCollection?: Collection,
claim: ?Claim,
collectionItemUrls: Array<string>, collectionItemUrls: Array<string>,
fetchCollectionItems: (string) => void, collectionThumbnail: ?string,
doFetchItemsInCollection: (options: CollectionFetchParams) => void,
}; };
function CollectionPreviewOverlay(props: Props) { function CollectionPreviewOverlay(props: Props) {
const { collectionId, collectionItemUrls, fetchCollectionItems } = props; const { collectionId, collectionItemUrls, collectionThumbnail, doFetchItemsInCollection } = props;
React.useEffect(() => { React.useEffect(() => {
if (!collectionItemUrls) { if (!collectionItemUrls) {
fetchCollectionItems(collectionId); doFetchItemsInCollection({ collectionId, pageSize: 3 });
} }
}, [collectionId, collectionItemUrls, fetchCollectionItems]); }, [collectionId, collectionItemUrls, doFetchItemsInCollection]);
if (collectionItemUrls && collectionItemUrls.length > 0) { if (!collectionItemUrls || collectionItemUrls.length === 0) {
const displayed = collectionItemUrls.slice(0, 2); return null;
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>
);
} }
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); 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 { doChannelMute } from 'redux/actions/blocked';
import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments'; import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments';
import { doOpenModal } from 'redux/actions/app'; 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 { doToast } from 'redux/actions/notifications';
import { selectClaimIsMine, selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimIsMine, selectClaimForUri } from 'redux/selectors/claims';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
@ -23,7 +23,7 @@ const select = (state, props) => {
const perform = (dispatch) => ({ const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)), openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })), clearPlayingUri: () => dispatch(doClearPlayingUri()),
muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)), muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)),
pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)), pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)),
commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) => commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) =>

View file

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

View file

@ -5,7 +5,7 @@ export const isTouch = () => {
return 'ontouchstart' in window || 'onmsgesturechange' in window; return 'ontouchstart' in window || 'onmsgesturechange' in window;
}; };
export const htmlToNode = html => { export const htmlToNode = (html) => {
const $wrap = window.document.createElement('div'); const $wrap = window.document.createElement('div');
$wrap.innerHTML = html; $wrap.innerHTML = html;
const $content = $wrap.childNodes; 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, onClick?: () => void,
children?: Node, children?: Node,
secondPane?: Node, secondPane?: Node,
slimHeader?: boolean,
colorHeader?: boolean,
singlePane?: boolean,
headerActions?: Node,
gridHeader?: boolean,
}; };
export default function Card(props: Props) { function Card(props: Props) {
const { const {
title, title,
subtitle, subtitle,
@ -45,7 +50,13 @@ export default function Card(props: Props) {
onClick, onClick,
children, children,
secondPane, secondPane,
slimHeader,
colorHeader,
singlePane,
headerActions,
gridHeader,
} = props; } = props;
const [expanded, setExpanded] = useState(defaultExpand); const [expanded, setExpanded] = useState(defaultExpand);
const expandable = defaultExpand !== undefined; const expandable = defaultExpand !== undefined;
@ -63,52 +74,61 @@ export default function Card(props: Props) {
} }
}} }}
> >
<div className="card__first-pane"> <FirstPaneWrapper singlePane={singlePane}>
{(title || subtitle) && ( {(title || subtitle) && (
<div <div
className={classnames('card__header--between', { className={classnames('card__header--between', {
'card__header--nowrap': noTitleWrap, 'card__header--nowrap': noTitleWrap,
'card__header--slim': slimHeader,
'card__header--bg-color': colorHeader,
'card__header--grid': gridHeader,
})} })}
> >
<div <div className={classnames('card__title-section', { 'card__title-section--body-list': isBodyList })}>
className={classnames('card__title-section', {
'card__title-section--body-list': isBodyList,
'card__title-section--smallx': smallTitle,
})}
>
{icon && <Icon sectionIcon icon={icon} />} {icon && <Icon sectionIcon icon={icon} />}
<div>
{isPageTitle && <h1 className="card__title">{title}</h1>} <div className="card__title-text">
{!isPageTitle && ( <TitleWrapper isPageTitle={isPageTitle} smallTitle={smallTitle}>
<h2 className={classnames('card__title', { 'card__title--small': smallTitle })}>{title}</h2> {title}
</TitleWrapper>
{subtitle && (
<div className={classnames('card__subtitle', { 'card__subtitle--small': smallTitle })}>
{subtitle}
</div>
)} )}
{subtitle && <div className="card__subtitle">{subtitle}</div>}
</div> </div>
</div> </div>
<div className="card__title-actions-container">
{titleActions && ( {(titleActions || expandable) && (
<div <div className="card__title-actions-container">
className={classnames('card__title-actions', { {titleActions && (
'card__title-actions--small': smallTitle, <div
})} className={classnames('card__title-actions', {
> 'card__title-actions--small': smallTitle,
{titleActions} })}
</div> >
)} {titleActions}
{expandable && ( </div>
<div className="card__title-actions"> )}
<Button {expandable && (
button="alt" <div className="card__title-actions">
aria-expanded={expanded} <Button
aria-label={expanded ? __('Less') : __('More')} button="alt"
icon={expanded ? ICONS.SUBTRACT : ICONS.ADD} aria-expanded={expanded}
onClick={() => setExpanded(!expanded)} aria-label={expanded ? __('Less') : __('More')}
/> icon={expanded ? ICONS.SUBTRACT : ICONS.ADD}
</div> onClick={() => setExpanded(!expanded)}
)} />
</div> </div>
)}
</div>
)}
{headerActions}
</div> </div>
)} )}
{(!expandable || (expandable && expanded)) && ( {(!expandable || (expandable && expanded)) && (
<> <>
{body && ( {body && (
@ -125,9 +145,40 @@ export default function Card(props: Props) {
{children && <div className="card__main-actions">{children}</div>} {children && <div className="card__main-actions">{children}</div>}
</> </>
)} )}
{nag} {nag}
</div> </FirstPaneWrapper>
{secondPane && <div className="card__second-pane">{secondPane}</div>} {secondPane && <div className="card__second-pane">{secondPane}</div>}
</section> </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'; import React from 'react';
type Props = { type Props = {
isChannel: boolean, isChannel?: boolean,
type: string, type?: string,
WrapperElement?: string,
xsmall?: boolean,
}; };
function ClaimPreviewLoading(props: Props) { function ClaimPreviewLoading(props: Props) {
const { isChannel, type } = props; const { isChannel, type, WrapperElement = 'li', xsmall } = props;
return ( 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', { className={classnames('placeholder claim-preview__wrapper', {
'claim-preview__wrapper--channel': isChannel && type !== 'inline', 'claim-preview__wrapper--channel': isChannel && type !== 'inline',
'claim-preview__wrapper--inline': 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={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="placeholder__wrapper">
<div className="claim-preview__title" /> <div className="claim-preview__title" />
<div className="claim-preview__title_b" /> <div className="claim-preview__title_b" />
@ -31,7 +35,7 @@ function ClaimPreviewLoading(props: Props) {
</div> </div>
</div> </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 React, { useEffect } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types';
import Freezeframe from './FreezeframeLite'; import Freezeframe from './FreezeframeLite';
import useLazyLoading from 'effects/use-lazy-loading'; 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 imgRef = React.useRef();
const freezeframe = React.useRef(); const freezeframe = React.useRef();
// eslint-disable-next-line
const { src, className, children } = props;
const srcLoaded = useLazyLoading(imgRef); const srcLoaded = useLazyLoading(imgRef);
@ -20,17 +26,10 @@ const FreezeframeWrapper = (props) => {
return ( return (
<div className={classnames(className, 'freezeframe-wrapper')}> <div className={classnames(className, 'freezeframe-wrapper')}>
<> <img ref={imgRef} data-src={src} className="freezeframe-img" />
<img ref={imgRef} data-src={src} className="freezeframe-img" /> {children}
{children}
</>
</div> </div>
); );
}; };
FreezeframeWrapper.propTypes = {
src: PropTypes.string.isRequired,
className: PropTypes.string.isRequired,
};
export default FreezeframeWrapper; export default FreezeframeWrapper;

View file

@ -2500,6 +2500,7 @@ export const icons = {
strokeLinejoin="round" strokeLinejoin="round"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
style={{ overflow: 'visible' }}
> >
<g transform="matrix(1,0,0,1,0,0)"> <g transform="matrix(1,0,0,1,0,0)">
<path d="M1.500 12.000 A10.500 10.500 0 1 0 22.500 12.000 A10.500 10.500 0 1 0 1.500 12.000 Z" /> <path d="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" /> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</g> </g>
), ),
[ICONS.REPEAT]: buildIcon( [ICONS.REPEAT]: (props: IconProps) => {
<g> const { size = 24, color = 'currentColor', ...otherProps } = props;
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" /> return (
<polyline points="7 23 3 19 7 15" /> <svg
<path d="M21 13v2a4 4 0 0 1-4 4H3" /> xmlns="http://www.w3.org/2000/svg"
</g> viewBox="0 0 24 24"
), width={size}
[ICONS.SHUFFLE]: buildIcon( height={size}
<g> fill="none"
<polyline points="16 3 21 3 21 8" /> strokeWidth="1.5"
<line x1="4" y1="20" x2="21" y2="3" /> strokeLinecap="round"
<polyline points="21 16 21 21 16 21" /> strokeLinejoin="round"
<line x1="15" y1="15" x2="21" y2="21" /> stroke={color}
<line x1="4" y1="4" x2="9" y2="9" /> {...otherProps}
</g> >
), <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( [ICONS.HOLD_PHONE]: buildIcon(
<g> <svg>
<path d="M12 17.491L1.5 17.491" /> <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="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" /> <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="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="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" /> <path d="M12.11,11.524a4.628,4.628,0,0,0-8.61.967" />
</g> </svg>
), ),
[ICONS.LIFE]: buildIcon( [ICONS.LIFE]: buildIcon(
<g> <g>
@ -3036,6 +3067,41 @@ export const icons = {
</g> </g>
</svg> </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) => ( [ICONS.PREMIUM]: (props: CustomProps) => (
<svg <svg
{...props} {...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" /> <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> </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, lines: number,
showTooltip?: boolean, showTooltip?: boolean,
children?: React.Node, children?: React.Node,
style?: any,
}; };
const TruncatedText = (props: Props) => { const TruncatedText = (props: Props) => {
const { text, children, lines, showTooltip } = props; const { text, children, lines, showTooltip, style } = props;
const tooltip = showTooltip ? children || text : ''; const tooltip = showTooltip ? children || text : '';
return ( return (
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines }}> <span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines, ...style }}>
{children || text} {children || text}
</span> </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 MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import Button from 'component/button';
import { buildURI } from 'util/lbryURI'; import { buildURI } from 'util/lbryURI';
import * as COLLECTIONS_CONSTS from 'constants/collections'; import * as COLLECTIONS_CONSTS from 'constants/collections';
import * as RENDER_MODES from 'constants/file_render_modes'; 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 { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { webDownloadClaim } from 'util/downloadClaim'; 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 = { type Props = {
uri: string, uri: string,
@ -121,53 +123,16 @@ export default function FileActions(props: Props) {
{!isAPreorder && <ClaimSupportButton uri={uri} fileAction />} {!isAPreorder && <ClaimSupportButton uri={uri} fileAction />}
<ClaimCollectionAddButton uri={uri} fileAction /> <ClaimCollectionAddButton uri={uri} />
{!hideRepost && !isMobile && !isLivestreamClaim && ( {!hideRepost && !isMobile && !isLivestreamClaim && <ClaimRepostButton uri={uri} />}
<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>
)}
<Tooltip title={__('Share')} arrow={false}> <ClaimShareButton uri={uri} fileAction webShareable={webShareable} collectionId={collectionId} />
<Button
className="button--file-action"
icon={ICONS.SHARE}
label={__('Share')}
onClick={() => doOpenModal(MODALS.SOCIAL_SHARE, { uri, webShareable, collectionId })}
/>
</Tooltip>
{claimIsMine && !isMobile && ( {claimIsMine && !isMobile && (
<> <>
<Tooltip title={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')} arrow={false}> <ClaimPublishButton uri={uri} isLivestreamClaim={isLivestreamClaim} />
<div style={{ margin: '0px' }}> <ClaimDeleteButton uri={uri} />
<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>
</> </>
)} )}

View file

@ -8,7 +8,7 @@ import {
} from 'redux/selectors/file_info'; } from 'redux/selectors/file_info';
import { selectCostInfoForUri } from 'lbryinc'; import { selectCostInfoForUri } from 'lbryinc';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import { doSetPlayingUri, doDownloadUri } from 'redux/actions/content'; import { doClearPlayingUri, doDownloadUri } from 'redux/actions/content';
import FileDownloadLink from './view'; import FileDownloadLink from './view';
const select = (state, props) => { const select = (state, props) => {
@ -27,7 +27,7 @@ const select = (state, props) => {
const perform = (dispatch) => ({ const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)), openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
pause: () => dispatch(doSetPlayingUri({ uri: null })), pause: () => dispatch(doClearPlayingUri()),
download: (uri) => dispatch(doDownloadUri(uri)), 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 * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import Button from 'component/button';
import RatioBar from 'component/ratioBar'; import RatioBar from 'component/ratioBar';
import { formatNumberWithCommas } from 'util/number'; import { formatNumberWithCommas } from 'util/number';
import NudgeFloating from 'component/nudgeFloating'; 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; const LIVE_REACTION_FETCH_MS = 1000 * 45;
@ -41,39 +40,6 @@ export default function FileReactions(props: Props) {
doReactionDislike, doReactionDislike,
} = props; } = 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(() => { React.useEffect(() => {
function fetchReactions() { function fetchReactions() {
doFetchReactions(claimId); doFetchReactions(claimId);
@ -104,64 +70,81 @@ export default function FileReactions(props: Props) {
/> />
)} )}
<div className={'ratio-wrapper'}> <div className="ratio-wrapper">
<Button <LikeButton myReaction={myReaction} reactionCount={likeCount} onClick={() => doReactionLike(uri)} />
title={__('I like this')} <DislikeButton myReaction={myReaction} reactionCount={dislikeCount} onClick={() => doReactionDislike(uri)} />
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)}
/>
<RatioBar likeCount={likeCount} dislikeCount={dislikeCount} /> <RatioBar likeCount={likeCount} dislikeCount={dislikeCount} />
</div> </div>
</> </>
); );
} }
/* type ButtonProps = {
type ReactionProps = { myReaction: ?string,
title: string, reactionCount: number,
label: any,
icon: string,
isActive: boolean,
activeClassName: string,
onClick: () => void, onClick: () => void,
}; };
const FileReaction = (reactionProps: ReactionProps) => { const LikeButton = (props: ButtonProps) => {
const { title, label, icon, isActive, activeClassName, onClick } = reactionProps; const { myReaction, reactionCount, onClick } = props;
return ( return (
<Tooltip title={title} arrow={false}> <FileActionButton
<div className="file-reaction__tooltip-inner"> title={__('I like this')}
<Button requiresAuth
requiresAuth authSrc="filereaction_like"
authSrc="filereaction_like" className={classnames('button--file-action button-like', {
className={classnames('button--file-action', { [activeClassName]: isActive })} 'button--fire': myReaction === REACTION_TYPES.LIKE,
label={label} })}
iconSize={18} label={
icon={icon} <>
onClick={onClick} {myReaction === REACTION_TYPES.LIKE && (
/> <>
</div> <div className="button__fire-glow" />
</Tooltip> <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, selectTitleForUri,
selectClaimWasPurchasedForUri, selectClaimWasPurchasedForUri,
selectGeoRestrictionForUri, selectGeoRestrictionForUri,
selectClaimIsNsfwForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info'; import { makeSelectStreamingUrlForUri } from 'redux/selectors/file_info';
import { import {
makeSelectNextUrlForCollectionAndUrl, selectCollectionForId,
makeSelectPreviousUrlForCollectionAndUrl, selectNextUrlForCollectionAndUrl,
selectPreviousUrlForCollectionAndUrl,
selectCollectionForIdHasClaimUrl,
selectFirstItemUrlForCollection,
} from 'redux/selectors/collections'; } from 'redux/selectors/collections';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import * as COLLECTIONS_CONSTS from 'constants/collections';
import { import {
makeSelectIsPlayerFloating, makeSelectIsPlayerFloating,
selectPrimaryUri, selectPrimaryUri,
@ -18,24 +23,32 @@ import {
makeSelectFileRenderModeForUri, makeSelectFileRenderModeForUri,
} from 'redux/selectors/content'; } from 'redux/selectors/content';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import { doClearQueueList } from 'redux/actions/collections';
import { selectCostInfoForUri } from 'lbryinc'; import { selectCostInfoForUri } from 'lbryinc';
import { doUriInitiatePlay, doSetPlayingUri, doClearPlayingUri } from 'redux/actions/content'; import { doUriInitiatePlay, doSetPlayingUri, doClearPlayingUri } from 'redux/actions/content';
import { doFetchRecommendedContent } from 'redux/actions/search'; import { doFetchRecommendedContent } from 'redux/actions/search';
import { withRouter } from 'react-router'; 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 { selectIsActiveLivestreamForUri, selectSocketConnectionForId } from 'redux/selectors/livestream';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket'; import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { isStreamPlaceholderClaim, getVideoClaimAspectRatio } from 'util/claim'; import { isStreamPlaceholderClaim, getVideoClaimAspectRatio } from 'util/claim';
import { doOpenModal } from 'redux/actions/app';
import FileRenderFloating from './view'; import FileRenderFloating from './view';
const select = (state, props) => { const select = (state, props) => {
const { location } = 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 playingUri = selectPlayingUri(state);
const { uri, collectionId } = playingUri || {}; const {
uri,
collection: { collectionId },
} = playingUri;
const claim = uri && selectClaimForUri(state, uri); 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 || {}; const { canonical_url: channelUrl } = channelClaim || {};
return { return {
@ -47,20 +60,27 @@ const select = (state, props) => {
title: selectTitleForUri(state, uri), title: selectTitleForUri(state, uri),
isFloating: makeSelectIsPlayerFloating(location)(state), isFloating: makeSelectIsPlayerFloating(location)(state),
streamingUrl: makeSelectStreamingUrlForUri(uri)(state), streamingUrl: makeSelectStreamingUrlForUri(uri)(state),
floatingPlayerEnabled: selectClientSetting(state, SETTINGS.FLOATING_PLAYER), floatingPlayerEnabled: playingUri.source === 'queue' || selectClientSetting(state, SETTINGS.FLOATING_PLAYER),
renderMode: makeSelectFileRenderModeForUri(uri)(state), renderMode: makeSelectFileRenderModeForUri(uri)(state),
videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE), videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE),
costInfo: selectCostInfoForUri(state, uri), costInfo: selectCostInfoForUri(state, uri),
claimWasPurchased: selectClaimWasPurchasedForUri(state, uri), claimWasPurchased: selectClaimWasPurchasedForUri(state, uri),
nextListUri: collectionId && makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state), nextListUri: collectionId && selectNextUrlForCollectionAndUrl(state, uri, collectionId),
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state), previousListUri: collectionId && selectPreviousUrlForCollectionAndUrl(state, uri, collectionId),
collectionId, collectionId,
collectionSidebarId,
playingCollection: selectCollectionForId(state, collectionId),
isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri), isCurrentClaimLive: selectIsActiveLivestreamForUri(state, uri),
videoAspectRatio: getVideoClaimAspectRatio(claim), videoAspectRatio: getVideoClaimAspectRatio(claim),
socketConnection: selectSocketConnectionForId(state, claimId), socketConnection: selectSocketConnectionForId(state, claimId),
isLivestreamClaim: isStreamPlaceholderClaim(claim), isLivestreamClaim: isStreamPlaceholderClaim(claim),
geoRestriction: selectGeoRestrictionForUri(state, uri), 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, doCommentSocketConnect,
doCommentSocketDisconnect, doCommentSocketDisconnect,
doClearPlayingUri, doClearPlayingUri,
doClearQueueList,
doOpenModal,
}; };
export default withRouter(connect(select, perform)(FileRenderFloating)); export default withRouter(connect(select, perform)(FileRenderFloating));

View file

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

View file

@ -1,10 +1,19 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { makeSelectClaimForUri } from 'redux/selectors/claims'; import { selectHasResolvedClaimForUri, selectThumbnailForUri } from 'redux/selectors/claims';
import CardMedia from './view'; import CardMedia from './view';
const select = (state, props) => ({ const select = (state, props) => {
claim: makeSelectClaimForUri(props.uri)(state), 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, fallback: ?string,
children?: Node, children?: Node,
className?: string, className?: string,
small?: boolean,
}; };
const Thumb = (props: Props) => { const Thumb = (props: Props) => {
const { thumb, fallback, children, className } = props; const { thumb, fallback, children, className, small } = props;
const thumbnailRef = React.useRef(null); const thumbnailRef = React.useRef(null);
useLazyLoading(thumbnailRef, fallback || ''); useLazyLoading(thumbnailRef, fallback || '');
return ( 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} {children}
</div> </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 { useIsMobile } from 'effects/use-screensize';
import { getImageProxyUrl, getThumbnailCdnUrl } from 'util/thumbnail'; import { getImageProxyUrl, getThumbnailCdnUrl } from 'util/thumbnail';
import React from 'react'; import React from 'react';
import FreezeframeWrapper from './FreezeframeWrapper'; import FreezeframeWrapper from 'component/common/freezeframe-wrapper';
import Placeholder from './placeholder.png';
import classnames from 'classnames'; import classnames from 'classnames';
import Thumb from './thumb'; import Thumb from './internal/thumb';
type Props = { type Props = {
uri: string, uri?: string,
tileLayout?: boolean, tileLayout?: boolean,
thumbnail: ?string, // externally sourced image thumbnail: ?string, // externally sourced image
children?: Node, children?: Node,
allowGifs: boolean, allowGifs: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
doResolveUri: (string) => void,
className?: string, 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) { function FileThumbnail(props: Props) {
const { const {
claim,
uri, uri,
tileLayout, tileLayout,
doResolveUri,
thumbnail: rawThumbnail, thumbnail: rawThumbnail,
children, children,
allowGifs = false, allowGifs = false,
className, className,
small,
forcePlaceholder,
// -- redux --
hasResolvedClaim,
thumbnailFromClaim,
doResolveUri,
} = props; } = 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 isMobile = useIsMobile();
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
const thumbnail = passedThumbnail || thumbnailFromClaim;
const isGif = thumbnail && thumbnail.endsWith('gif');
React.useEffect(() => { React.useEffect(() => {
if (!hasResolvedClaim && uri && !passedThumbnail) { if (hasResolvedClaim === false && uri && !passedThumbnail) {
doResolveUri(uri); doResolveUri(uri);
} }
}, [hasResolvedClaim, uri, doResolveUri, passedThumbnail]); }, [hasResolvedClaim, passedThumbnail, doResolveUri, uri]);
if (!allowGifs && isGif) { if (!allowGifs && isGif) {
const url = getImageProxyUrl(thumbnail); const url = getImageProxyUrl(thumbnail);
return ( return (
<FreezeframeWrapper src={url} className={classnames('media__thumb', className)}> url && (
{children} <FreezeframeWrapper
</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; const fallback = MISSING_THUMB_DEFAULT ? getThumbnailCdnUrl({ thumbnail: MISSING_THUMB_DEFAULT }) : undefined;
let url = thumbnail || (hasResolvedClaim ? Placeholder : ''); let url = thumbnail || (hasResolvedClaim ? MISSING_THUMB_DEFAULT : '');
// @if TARGET='web'
// Pass image urls through a compression proxy // Pass image urls through a compression proxy
if (thumbnail) { if (thumbnail) {
if (isGif) { if (isGif) {
@ -80,21 +94,22 @@ function FileThumbnail(props: Props) {
}); });
} }
} }
// @endif
const thumbnailUrl = url ? url.replace(/'/g, "\\'") : ''; const thumbnailUrl = url ? url.replace(/'/g, "\\'") : '';
if (hasResolvedClaim || thumbnailUrl) { if (hasResolvedClaim || thumbnailUrl || (forcePlaceholder && !uri)) {
return ( return (
<Thumb thumb={thumbnailUrl} fallback={fallback} className={className}> <Thumb small={small} thumb={thumbnailUrl || MISSING_THUMB_DEFAULT} fallback={fallback} className={className}>
{children} {children}
</Thumb> </Thumb>
); );
} }
return ( return (
<div <div
className={classnames('media__thumb', className, { className={classnames('media__thumb', className, {
'media__thumb--resolving': !hasResolvedClaim, 'media__thumb--resolving': hasResolvedClaim === false,
'media__thumb--small': small,
})} })}
> >
{children} {children}

View file

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

View file

@ -3,32 +3,49 @@ import * as ICONS from 'constants/icons';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import Button from 'component/button'; import Button from 'component/button';
import useHover from 'effects/use-hover'; import useHover from 'effects/use-hover';
import { useHistory } from 'react-router';
import * as COLLECTIONS_CONSTS from 'constants/collections'; import * as COLLECTIONS_CONSTS from 'constants/collections';
import { formatLbryUrlForWeb } from 'util/url';
type Props = { type Props = {
uri: string, uri: string,
focusable: boolean, focusable: boolean,
hasClaimInWatchLater: boolean, hasClaimInWatchLater: boolean,
doToast: ({ message: string }) => void, doPlaylistAddAndAllowPlaying: (params: {
doCollectionEdit: (string, any) => void, uri: string,
collectionName: string,
collectionId: string,
push: (uri: string) => void,
}) => void,
}; };
function FileWatchLaterLink(props: Props) { 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(); const buttonRef = useRef();
let isHovering = useHover(buttonRef); let isHovering = useHover(buttonRef);
function handleWatchLater(e) { function handleWatchLater(e) {
e.preventDefault(); if (e) e.preventDefault();
doToast({
message: hasClaimInWatchLater ? __('Item removed from Watch Later') : __('Item added to Watch Later'), const urlParams = new URLSearchParams(search);
linkText: !hasClaimInWatchLater && __('See All'), urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, COLLECTIONS_CONSTS.WATCH_LATER_ID);
linkTarget: !hasClaimInWatchLater && '/list/watchlater',
}); doPlaylistAddAndAllowPlaying({
doCollectionEdit(COLLECTIONS_CONSTS.WATCH_LATER_ID, { uri,
uris: [uri], collectionName: COLLECTIONS_CONSTS.WATCH_LATER_NAME,
remove: hasClaimInWatchLater, collectionId: COLLECTIONS_CONSTS.WATCH_LATER_ID,
type: 'playlist', 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'); const label = !hasClaimInWatchLater ? __('Watch Later') : __('Remove');
return ( return (
<Button <div className="claim-preview__hover-actions second-item">
ref={buttonRef} <Button
requiresAuth={IS_WEB} ref={buttonRef}
title={title} requiresAuth
label={label} title={title}
className="button--file-action" label={label}
icon={ className="button--file-action"
(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) || icon={(hasClaimInWatchLater && (isHovering ? ICONS.REMOVE : ICONS.COMPLETED)) || ICONS.TIME}
(isHovering ? ICONS.COMPLETED : ICONS.TIME) onClick={(e) => handleWatchLater(e)}
} tabIndex={focusable ? 0 : -1}
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 FileRenderInitiator from 'component/fileRenderInitiator';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo'; import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as DRAWERS from 'constants/drawer_types';
import SwipeableDrawer from 'component/swipeableDrawer'; import SwipeableDrawer from 'component/swipeableDrawer';
import DrawerExpandButton from 'component/swipeableDrawerExpand'; import DrawerExpandButton from 'component/swipeableDrawerExpand';
import LivestreamMenu from 'component/livestreamChatLayout/livestream-menu'; import LivestreamMenu from 'component/livestreamChatLayout/livestream-menu';
@ -104,6 +105,8 @@ export default function LivestreamLayout(props: Props) {
{isMobile && !isLandscapeRotated && !hideComments && ( {isMobile && !isLandscapeRotated && !hideComments && (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<SwipeableDrawer <SwipeableDrawer
startOpen
type={DRAWERS.CHAT}
title={ title={
<ChatModeSelector <ChatModeSelector
superChats={superChats} superChats={superChats}
@ -131,7 +134,7 @@ export default function LivestreamLayout(props: Props) {
/> />
</SwipeableDrawer> </SwipeableDrawer>
<DrawerExpandButton label={__('Open Live Chat')} /> <DrawerExpandButton icon={ICONS.CHAT} label={__('Open Live Chat')} type={DRAWERS.CHAT} />
</React.Suspense> </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