1e67a5cc7f
* Factor out lighthouse-result processing code for FYP re-use. The FYP results will be in the same format as LH. * Recsys: add ability to pass in specific uuid to use For FYP, we want to pass the UUID as a param when searching for recommendations. The search comes before the recsys entry creation, so we need to generate the UUID first when searching, and then tell recsys to use that specific ID. * Redux: fetch and store FYP Note that the gid cannot be used as "hash" for the uri list -- it doesn't necessarily change when the list changes, so we can't use it to optimize redux. For now, just always update/render when re-fetched. * UI for FYP * Mark rendered FYPs * Pass the FYP ID down the same way as Collection ID Not ideal, but at least it's in the same pattern as existing code for now. The whole prop-drilling problem with the claim components will be fixed together later. * Include 'gid' and 'uuid' in recommendation search * Allow users to mark recommendations that they dislike * Pass auth-token to all FYP requests + remove beacon use beacons are unreliable and often blocked * Only show FYP for members * FYP readme page * small fixes * fyp Co-authored-by: Thomas Zarebczan <thomas.zarebczan@gmail.com>
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
// @flow
|
|
import { MAIN_CLASS } from 'constants/classnames';
|
|
import type { Node } from 'react';
|
|
import React, { useEffect } from 'react';
|
|
import classnames from 'classnames';
|
|
import ClaimPreview from 'component/claimPreview';
|
|
import Spinner from 'component/spinner';
|
|
import { FormField } from 'component/common/form';
|
|
import usePersistedState from 'effects/use-persisted-state';
|
|
import useLastVisibleItem from 'effects/use-last-visible-item';
|
|
import debounce from 'util/debounce';
|
|
import ClaimPreviewTile from 'component/claimPreviewTile';
|
|
|
|
const Draggable = React.lazy(() =>
|
|
// $FlowFixMe
|
|
import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Draggable }))
|
|
);
|
|
|
|
const DEBOUNCE_SCROLL_HANDLER_MS = 150;
|
|
const SORT_NEW = 'new';
|
|
const SORT_OLD = 'old';
|
|
|
|
type Props = {
|
|
uris: Array<string>,
|
|
prefixUris?: Array<string>,
|
|
header: Node | boolean,
|
|
headerAltControls: Node,
|
|
loading: boolean,
|
|
useLoadingSpinner?: boolean, // use built-in spinner when 'loading' is true. Else, roll your own at client-side.
|
|
type: string,
|
|
activeUri?: string,
|
|
empty?: string,
|
|
defaultSort?: boolean,
|
|
onScrollBottom?: (any) => void,
|
|
page?: number,
|
|
pageSize?: number,
|
|
// If using the default header, this is a unique ID needed to persist the state of the filter setting
|
|
persistedStorageKey?: string,
|
|
showHiddenByUser: boolean,
|
|
showUnresolvedClaims?: boolean,
|
|
renderActions?: (Claim) => ?Node,
|
|
renderProperties?: (Claim) => ?Node,
|
|
includeSupportAction?: boolean,
|
|
injectedItem?: { node: Node, index?: number, replace?: boolean },
|
|
timedOutMessage?: Node,
|
|
tileLayout?: boolean,
|
|
searchInLanguage: boolean,
|
|
hideMenu?: boolean,
|
|
claimSearchByQuery: { [string]: Array<string> },
|
|
claimsByUri: { [string]: any },
|
|
collectionId?: string,
|
|
fypId?: string,
|
|
showNoSourceClaims?: boolean,
|
|
onClick?: (e: any, claim?: ?Claim, index?: number) => void,
|
|
maxClaimRender?: number,
|
|
excludeUris?: Array<string>,
|
|
loadedCallback?: (number) => void,
|
|
swipeLayout: boolean,
|
|
showEdit?: boolean,
|
|
droppableProvided?: any,
|
|
unavailableUris?: Array<string>,
|
|
showMemberBadge?: boolean,
|
|
};
|
|
|
|
export default function ClaimList(props: Props) {
|
|
const {
|
|
activeUri,
|
|
uris,
|
|
prefixUris,
|
|
headerAltControls,
|
|
loading,
|
|
useLoadingSpinner,
|
|
persistedStorageKey,
|
|
empty,
|
|
defaultSort,
|
|
type,
|
|
header,
|
|
onScrollBottom,
|
|
pageSize,
|
|
page,
|
|
showHiddenByUser,
|
|
showUnresolvedClaims,
|
|
includeSupportAction,
|
|
injectedItem,
|
|
timedOutMessage,
|
|
tileLayout = false,
|
|
renderActions,
|
|
renderProperties,
|
|
searchInLanguage,
|
|
hideMenu,
|
|
collectionId,
|
|
fypId,
|
|
showNoSourceClaims,
|
|
onClick,
|
|
maxClaimRender,
|
|
excludeUris = [],
|
|
loadedCallback,
|
|
swipeLayout = false,
|
|
showEdit,
|
|
droppableProvided,
|
|
unavailableUris,
|
|
showMemberBadge,
|
|
} = props;
|
|
|
|
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
|
|
|
|
// Resolve the index for injectedItem, if provided; else injectedIndex will be 'undefined'.
|
|
const listRef = React.useRef();
|
|
const injectedIndex = useLastVisibleItem(injectedItem, listRef);
|
|
|
|
// Exclude prefix uris in these results variables. We don't want to show
|
|
// anything if the search failed or timed out.
|
|
const timedOut = uris === null;
|
|
const urisLength = (uris && uris.length) || 0;
|
|
|
|
let tileUris = (prefixUris || []).concat(uris || []);
|
|
tileUris = tileUris.filter((uri) => !excludeUris.includes(uri));
|
|
if (prefixUris && prefixUris.length) tileUris.splice(prefixUris.length * -1, prefixUris.length);
|
|
|
|
const totalLength = tileUris.length;
|
|
|
|
if (maxClaimRender) tileUris = tileUris.slice(0, maxClaimRender);
|
|
|
|
let sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || [];
|
|
|
|
React.useEffect(() => {
|
|
if (typeof loadedCallback === 'function') loadedCallback(totalLength);
|
|
}, [totalLength]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const noResultMsg = searchInLanguage
|
|
? __('No results. Contents may be hidden by the Language filter.')
|
|
: __('No results');
|
|
|
|
function handleSortChange() {
|
|
setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW);
|
|
}
|
|
|
|
const handleClaimClicked = React.useCallback(
|
|
(e, claim, index) => {
|
|
if (onClick) {
|
|
onClick(e, claim, index);
|
|
}
|
|
},
|
|
[onClick]
|
|
);
|
|
|
|
const customShouldHide = React.useCallback((claim: StreamClaim) => {
|
|
// Hack to hide spee.ch thumbnail publishes
|
|
// If it meets these requirements, it was probably uploaded here:
|
|
// https://github.com/lbryio/lbry-redux/blob/master/src/redux/actions/publish.js#L74-L79
|
|
return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch';
|
|
}, []);
|
|
|
|
// @if process.env.NODE_ENV!='production'
|
|
if (injectedItem && injectedItem.replace) {
|
|
throw new Error('claimList: "injectedItem.replace" is not implemented yet');
|
|
}
|
|
// @endif
|
|
|
|
useEffect(() => {
|
|
const handleScroll = debounce((e) => {
|
|
if (page && pageSize && onScrollBottom) {
|
|
const mainEl = document.querySelector(`.${MAIN_CLASS}`);
|
|
if (mainEl && !loading && urisLength >= pageSize) {
|
|
const ROUGH_TILE_HEIGHT_PX = 200;
|
|
const mainBoundingRect = mainEl.getBoundingClientRect();
|
|
const contentWrapperAtBottomOfPage = mainBoundingRect.bottom - ROUGH_TILE_HEIGHT_PX <= window.innerHeight;
|
|
if (contentWrapperAtBottomOfPage) {
|
|
onScrollBottom();
|
|
}
|
|
}
|
|
}
|
|
}, DEBOUNCE_SCROLL_HANDLER_MS);
|
|
|
|
if (onScrollBottom) {
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}
|
|
}, [loading, onScrollBottom, urisLength, pageSize, page]);
|
|
|
|
const getClaimPreview = (uri: string, index: number, draggableProvided?: any) => (
|
|
<ClaimPreview
|
|
uri={uri}
|
|
key={uri}
|
|
indexInContainer={index}
|
|
type={type}
|
|
active={activeUri && uri === activeUri}
|
|
hideMenu={hideMenu}
|
|
includeSupportAction={includeSupportAction}
|
|
showUnresolvedClaim={showUnresolvedClaims}
|
|
properties={renderProperties || (type !== 'small' ? undefined : false)}
|
|
renderActions={renderActions}
|
|
showUserBlocked={showHiddenByUser}
|
|
showHiddenByUser={showHiddenByUser}
|
|
collectionId={collectionId}
|
|
showNoSourceClaims={showNoSourceClaims}
|
|
customShouldHide={customShouldHide}
|
|
onClick={handleClaimClicked}
|
|
swipeLayout={swipeLayout}
|
|
showEdit={showEdit}
|
|
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
|
|
unavailableUris={unavailableUris}
|
|
showMemberBadge={showMemberBadge}
|
|
/>
|
|
);
|
|
|
|
const getInjectedItem = (index) => {
|
|
if (injectedItem && injectedItem.node && injectedIndex === index) {
|
|
return injectedItem.node;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return tileLayout && !header ? (
|
|
<>
|
|
<section ref={listRef} className={classnames('claim-grid', { 'swipe-list': swipeLayout })}>
|
|
{urisLength > 0 &&
|
|
tileUris.map((uri, index) => (
|
|
<React.Fragment key={uri}>
|
|
{getInjectedItem(index)}
|
|
<ClaimPreviewTile
|
|
uri={uri}
|
|
showHiddenByUser={showHiddenByUser}
|
|
properties={renderProperties}
|
|
collectionId={collectionId}
|
|
fypId={fypId}
|
|
showNoSourceClaims={showNoSourceClaims}
|
|
swipeLayout={swipeLayout}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
{!timedOut && urisLength === 0 && !loading && <div className="empty main--empty">{empty || noResultMsg}</div>}
|
|
{timedOut && timedOutMessage && <div className="empty main--empty">{timedOutMessage}</div>}
|
|
</section>
|
|
{loading && useLoadingSpinner && (
|
|
<div className="spinnerArea--centered">
|
|
<Spinner type="small" />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<section
|
|
className={classnames('claim-list', {
|
|
'claim-list--small': type === 'small',
|
|
})}
|
|
>
|
|
{header !== false && (
|
|
<React.Fragment>
|
|
{header && (
|
|
<div className={classnames('claim-list__header', { 'section__title--small': type === 'small' })}>
|
|
{header}
|
|
{loading && <Spinner type="small" />}
|
|
{(headerAltControls || defaultSort) && (
|
|
<div className="claim-list__alt-controls">
|
|
{headerAltControls}
|
|
{defaultSort && (
|
|
<FormField
|
|
className="claim-list__dropdown"
|
|
type="select"
|
|
name="file_sort"
|
|
value={currentSort}
|
|
onChange={handleSortChange}
|
|
>
|
|
<option value={SORT_NEW}>{__('Newest First')}</option>
|
|
<option value={SORT_OLD}>{__('Oldest First')}</option>
|
|
</FormField>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
|
|
{urisLength > 0 && (
|
|
<ul
|
|
className={classnames('ul--no-style', {
|
|
card: !(tileLayout || swipeLayout || type === 'small'),
|
|
'claim-list--card-body': tileLayout,
|
|
'swipe-list': swipeLayout,
|
|
})}
|
|
{...(droppableProvided && droppableProvided.droppableProps)}
|
|
ref={droppableProvided ? droppableProvided.innerRef : listRef}
|
|
>
|
|
{droppableProvided ? (
|
|
<>
|
|
{sortedUris.map((uri, index) => (
|
|
<React.Suspense fallback={null} key={uri}>
|
|
<Draggable draggableId={uri} index={index}>
|
|
{(draggableProvided, draggableSnapshot) => {
|
|
// Restrict dragging to vertical axis
|
|
// https://github.com/atlassian/react-beautiful-dnd/issues/958#issuecomment-980548919
|
|
let transform = draggableProvided.draggableProps.style.transform;
|
|
if (draggableSnapshot.isDragging && transform) {
|
|
transform = transform.replace(/\(.+,/, '(0,');
|
|
}
|
|
|
|
const style = {
|
|
...draggableProvided.draggableProps.style,
|
|
transform,
|
|
};
|
|
|
|
return (
|
|
<li ref={draggableProvided.innerRef} {...draggableProvided.draggableProps} style={style}>
|
|
{/* https://github.com/atlassian/react-beautiful-dnd/issues/1756 */}
|
|
<div style={{ display: 'none' }} {...draggableProvided.dragHandleProps} />
|
|
{getClaimPreview(uri, index, draggableProvided)}
|
|
</li>
|
|
);
|
|
}}
|
|
</Draggable>
|
|
</React.Suspense>
|
|
))}
|
|
{droppableProvided.placeholder}
|
|
</>
|
|
) : (
|
|
sortedUris.map((uri, index) => (
|
|
<React.Fragment key={uri}>
|
|
{getInjectedItem(index)}
|
|
{getClaimPreview(uri, index)}
|
|
</React.Fragment>
|
|
))
|
|
)}
|
|
</ul>
|
|
)}
|
|
|
|
{!timedOut && urisLength === 0 && !loading && <div className="empty empty--centered">{empty || noResultMsg}</div>}
|
|
{!loading && timedOut && timedOutMessage && <div className="empty empty--centered">{timedOutMessage}</div>}
|
|
{loading && useLoadingSpinner && (
|
|
<div className="spinnerArea--centered">
|
|
<Spinner type="small" />
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|