lbry-desktop/ui/component/claimListHeader/view.jsx
infinite-persistence b0e88ff5d1
Add "Hide Repost" button in channel page (#1796)
* Re-organized per 'state->var->func->effect->return' structure.

* Add "Hide Repost" button in channel page

Ticket: 1762

## Change
For the placement of the button, putting it inside the expanded settings group feels the most natural, plus we then don't need to check whether the channel has reposts or not before displaying it (the expanded area is for stuff like this).

## Notes
The tricky part was making the code maintainable w.r.t to the global "Hide Repost" setting. Changed `forceShowReposts` to `hideRepostsOverride`, hopefully makes things more obvious.
- undefined = fallback to global setting
- true/false = use override
2022-07-05 13:20:10 -04:00

542 lines
20 KiB
JavaScript

// @flow
import * as CS from 'constants/claim_search';
import * as ICONS from 'constants/icons';
import * as SETTINGS from 'constants/settings';
import type { Node } from 'react';
import classnames from 'classnames';
import React from 'react';
import usePersistedState from 'effects/use-persisted-state';
import { useHistory } from 'react-router';
import { FormField } from 'component/common/form';
import Button from 'component/button';
import { toCapitalCase } from 'util/string';
import SEARCHABLE_LANGUAGES from 'constants/searchable_languages';
import { ClaimSearchFilterContext } from 'contexts/claimSearchFilterContext';
type Props = {
defaultTags: string,
freshness?: string,
defaultFreshness?: string,
claimType?: Array<string>,
streamType?: string | Array<string>,
defaultStreamType?: string | Array<string>,
feeAmount: string,
sortBy?: string,
orderBy?: Array<string>,
defaultOrderBy?: string,
hideAdvancedFilter: boolean,
hideLayoutButton: boolean,
hasMatureTags: boolean,
hiddenNsfwMessage?: Node,
channelIds?: Array<string>,
tileLayout: boolean,
doSetClientSetting: (string, boolean, ?boolean) => void,
setPage: (number) => void,
hideFilters: boolean,
searchInLanguage: boolean,
languageSetting: string,
scrollAnchor?: string,
};
function ClaimListHeader(props: Props) {
const {
defaultTags,
freshness,
defaultFreshness,
claimType,
streamType,
defaultStreamType,
feeAmount,
sortBy,
orderBy,
defaultOrderBy,
hideAdvancedFilter,
hideLayoutButton,
hasMatureTags,
hiddenNsfwMessage,
channelIds,
tileLayout,
doSetClientSetting,
setPage,
hideFilters,
searchInLanguage,
languageSetting,
scrollAnchor,
} = props;
const filterCtx = React.useContext(ClaimSearchFilterContext);
const { action, push, location } = useHistory();
const { search } = location;
const [expanded, setExpanded] = usePersistedState(`expanded-${location.pathname}`, false);
const [orderParamEntry, setOrderParamEntry] = usePersistedState(`entry-${location.pathname}`, CS.ORDER_BY_TRENDING);
const [orderParamUser, setOrderParamUser] = usePersistedState(`orderUser-${location.pathname}`, CS.ORDER_BY_TRENDING);
const urlParams = new URLSearchParams(search);
const freshnessParam = freshness || urlParams.get(CS.FRESH_KEY) || defaultFreshness;
const contentTypeParam = urlParams.get(CS.CONTENT_KEY);
const streamTypeParam =
streamType || (CS.FILE_TYPES.includes(contentTypeParam) && contentTypeParam) || defaultStreamType || null;
const durationParam = urlParams.get(CS.DURATION_KEY) || null;
const languageParam = urlParams.get(CS.LANGUAGE_KEY) || null;
const sortByParam = sortBy || urlParams.get(CS.SORT_BY_KEY) || null;
const channelIdsInUrl = urlParams.get(CS.CHANNEL_IDS_KEY);
const channelIdsParam = channelIdsInUrl ? channelIdsInUrl.split(',') : channelIds;
const feeAmountParam = urlParams.get('fee_amount') || feeAmount || CS.FEE_AMOUNT_ANY;
const showDuration = !(claimType && claimType === CS.CLAIM_CHANNEL && claimType === CS.CLAIM_COLLECTION);
const isFiltered = () =>
Boolean(
urlParams.get(CS.FRESH_KEY) ||
urlParams.get(CS.CONTENT_KEY) ||
urlParams.get(CS.DURATION_KEY) ||
urlParams.get(CS.TAGS_KEY) ||
urlParams.get(CS.FEE_AMOUNT_KEY) ||
urlParams.get(CS.LANGUAGE_KEY) ||
filterCtx?.repost?.hideReposts
);
const languageValue = searchInLanguage
? languageParam === null
? languageSetting
: languageParam
: languageParam === null
? CS.LANGUAGES_ALL
: languageParam;
const shouldHighlight = searchInLanguage
? languageParam !== languageSetting && languageParam !== null
: languageParam !== CS.LANGUAGES_ALL && languageParam !== null;
function getHideRepostsElem(filterCtx, contentType) {
if (filterCtx?.repost) {
return (
<div className={classnames(`card claim-search__menus`)}>
<FormField
label={__('Hide reposts')}
name="hide_reposts"
type="checkbox"
checked={filterCtx.repost.hideReposts}
disabled={contentType === CS.CLAIM_REPOST}
onChange={() => {
filterCtx.repost.setHideReposts((prev) => !prev);
}}
/>
</div>
);
} else {
return null;
}
}
React.useEffect(() => {
if (action !== 'POP' && isFiltered()) {
setExpanded(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (hideAdvancedFilter) {
setExpanded(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let orderParam = orderBy || urlParams.get(CS.ORDER_BY_KEY) || defaultOrderBy;
if (!orderParam) {
if (action === 'POP') {
// Reaching here means user have popped back to the page's entry point (e.g. '/$/tags' without any '?order=').
orderParam = orderParamEntry;
} else {
// This is the direct entry into the page, so we load the user's previous value.
orderParam = orderParamUser;
}
}
React.useEffect(() => {
setOrderParamUser(orderParam);
}, [orderParam, setOrderParamUser]);
React.useEffect(() => {
// One-time update to stash the finalized 'orderParam' at entry.
if (action !== 'POP') {
setOrderParamEntry(orderParam);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleChange(change) {
const url = buildUrl(change);
setPage(1);
push(url);
}
function handleAdvancedReset() {
const newUrlParams = new URLSearchParams(search);
newUrlParams.delete('claim_type');
newUrlParams.delete('channel_ids');
const newSearch = `?${newUrlParams.toString()}`;
push(newSearch);
}
function buildUrl(delta) {
const newUrlParams = new URLSearchParams(location.search);
CS.KEYS.forEach((k) => {
// $FlowFixMe get() can return null
if (urlParams.get(k) !== null) newUrlParams.set(k, urlParams.get(k));
});
switch (delta.key) {
case CS.ORDER_BY_KEY:
newUrlParams.set(CS.ORDER_BY_KEY, delta.value);
break;
case CS.SORT_BY_KEY:
if (delta.value === CS.SORT_BY.NEWEST.key) {
newUrlParams.delete(CS.SORT_BY_KEY);
} else {
newUrlParams.set(CS.SORT_BY_KEY, delta.value);
}
break;
case CS.FRESH_KEY:
if (delta.value === defaultFreshness || delta.value === CS.FRESH_DEFAULT) {
newUrlParams.delete(CS.FRESH_KEY);
} else {
newUrlParams.set(CS.FRESH_KEY, delta.value);
}
break;
case CS.CONTENT_KEY:
if (
delta.value === CS.CLAIM_CHANNEL ||
delta.value === CS.CLAIM_REPOST ||
delta.value === CS.CLAIM_COLLECTION
) {
newUrlParams.delete(CS.DURATION_KEY);
newUrlParams.set(CS.CONTENT_KEY, delta.value);
} else if (delta.value === CS.CONTENT_ALL) {
newUrlParams.delete(CS.CONTENT_KEY);
} else {
newUrlParams.set(CS.CONTENT_KEY, delta.value);
}
break;
case CS.DURATION_KEY:
if (delta.value === CS.DURATION_ALL) {
newUrlParams.delete(CS.DURATION_KEY);
} else {
newUrlParams.set(CS.DURATION_KEY, delta.value);
}
break;
case CS.LANGUAGE_KEY:
newUrlParams.set(CS.LANGUAGE_KEY, delta.value);
break;
case CS.TAGS_KEY:
if (delta.value === CS.TAGS_ALL) {
if (defaultTags === CS.TAGS_ALL) {
newUrlParams.delete(CS.TAGS_KEY);
} else {
newUrlParams.set(CS.TAGS_KEY, delta.value);
}
} else if (delta.value === CS.TAGS_FOLLOWED) {
if (defaultTags === CS.TAGS_FOLLOWED) {
newUrlParams.delete(CS.TAGS_KEY);
} else {
newUrlParams.set(CS.TAGS_KEY, delta.value); // redundant but special
}
} else {
newUrlParams.set(CS.TAGS_KEY, delta.value);
}
break;
case CS.FEE_AMOUNT_KEY:
if (delta.value === CS.FEE_AMOUNT_ANY) {
newUrlParams.delete(CS.FEE_AMOUNT_KEY);
} else {
newUrlParams.set(CS.FEE_AMOUNT_KEY, delta.value);
}
break;
}
return `?${newUrlParams.toString()}` + (scrollAnchor ? '#' + scrollAnchor : '');
}
return (
<>
<div className="claim-search__wrapper">
<div className="claim-search__top">
{!hideFilters && (
<div className="claim-search__menu-group">
{CS.ORDER_BY_TYPES.map((type) => (
<Button
key={type}
button="alt"
onClick={(e) =>
handleChange({
key: CS.ORDER_BY_KEY,
value: type,
})
}
className={classnames(`button-toggle button-toggle--${type}`, {
'button-toggle--active': orderParam === type,
})}
disabled={orderBy}
icon={toCapitalCase(type)}
iconSize={toCapitalCase(type) === ICONS.NEW ? 20 : undefined}
label={__(toCapitalCase(type))}
/>
))}
</div>
)}
<div className="claim-search__menu-group">
{!hideAdvancedFilter && (
<Button
button="alt"
aria-label={__('More')}
className={classnames(`button-toggle button-toggle--top button-toggle--more`, {
'button-toggle--custom': isFiltered(),
'button-toggle--active': expanded,
})}
icon={ICONS.SLIDERS}
onClick={() => setExpanded(!expanded)}
/>
)}
{tileLayout !== undefined && !hideLayoutButton && (
<Button
onClick={() => {
doSetClientSetting(SETTINGS.TILE_LAYOUT, !tileLayout);
}}
button="alt"
className="button-toggle"
aria-label={tileLayout ? __('Change to list layout') : __('Change to tile layout')}
icon={ICONS.LAYOUT}
/>
)}
</div>
</div>
{expanded && (
<>
<div className={classnames(`card claim-search__menus`)}>
{/* FRESHNESS FIELD */}
{orderParam === CS.ORDER_BY_TOP && (
<div className="claim-search__input-container">
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': freshnessParam !== defaultFreshness,
})}
type="select"
name="trending_time"
label={__('How Fresh')}
value={freshnessParam}
onChange={(e) =>
handleChange({
key: CS.FRESH_KEY,
value: e.target.value,
})
}
>
{CS.FRESH_TYPES.map((time) => (
<option key={time} value={time}>
{/* i18fixme */}
{time === CS.FRESH_DAY && __('Today')}
{
time !== CS.FRESH_ALL &&
time !== CS.FRESH_DEFAULT &&
time !== CS.FRESH_DAY &&
__('This ' + toCapitalCase(time)) /* yes, concat before i18n, since it is read from const */
}
{time === CS.FRESH_ALL && __('All time')}
{time === CS.FRESH_DEFAULT && __('Default')}
</option>
))}
</FormField>
</div>
)}
{/* CONTENT_TYPES FIELD - display using same logic as showDuration */}
{showDuration && (
<div
className={classnames('claim-search__input-container', {
'claim-search__input-container--selected': contentTypeParam,
})}
>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': contentTypeParam,
})}
type="select"
name="claimType"
label={__('Content Type')}
value={contentTypeParam || CS.CONTENT_ALL}
onChange={(e) =>
handleChange({
key: CS.CONTENT_KEY,
value: e.target.value,
})
}
>
{filterCtx.contentTypes.map((type) => {
if (type !== CS.CLAIM_CHANNEL || (type === CS.CLAIM_CHANNEL && !channelIdsParam)) {
return (
<option key={type} value={type}>
{/* i18fixme */}
{type === CS.CLAIM_COLLECTION && __('List')}
{type === CS.CLAIM_CHANNEL && __('Channel')}
{type === CS.CLAIM_REPOST && __('Repost')}
{type === CS.FILE_VIDEO && __('Video')}
{type === CS.FILE_AUDIO && __('Audio')}
{type === CS.FILE_IMAGE && __('Image')}
{type === CS.FILE_MODEL && __('Model')}
{type === CS.FILE_BINARY && __('Other')}
{type === CS.FILE_DOCUMENT && __('Document')}
{type === CS.CONTENT_ALL && __('Any')}
</option>
);
}
})}
</FormField>
</div>
)}
{/* LANGUAGE FIELD - hidden for now */}
{false && !claimType && (
<div
className={classnames('claim-search__input-container', {
'claim-search__input-container--selected': shouldHighlight,
})}
>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': shouldHighlight,
})}
type="select"
name="claimType"
label={__('Language')}
value={languageValue || CS.LANGUAGES_ALL}
onChange={(e) =>
handleChange({
key: CS.LANGUAGE_KEY,
value: e.target.value,
})
}
>
<option key={CS.LANGUAGES_ALL} value={CS.LANGUAGES_ALL}>
{__('Any')}
{/* i18fixme */}
</option>
{Object.entries(SEARCHABLE_LANGUAGES).map(([code, label]) => {
return (
<option key={code} value={code}>
{String(label)}
</option>
);
})}
</FormField>
</div>
)}
{/* DURATIONS FIELD */}
{showDuration && (
<div className={'claim-search__input-container'}>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': durationParam,
})}
label={__('Duration --[length of audio or video]--')}
type="select"
name="duration"
disabled={
!(
contentTypeParam === null ||
streamTypeParam === CS.FILE_AUDIO ||
streamTypeParam === CS.FILE_VIDEO
)
}
value={durationParam || CS.DURATION_ALL}
onChange={(e) =>
handleChange({
key: CS.DURATION_KEY,
value: e.target.value,
})
}
>
{CS.DURATION_TYPES.map((dur) => (
<option key={dur} value={dur}>
{/* i18fixme */}
{dur === CS.DURATION_SHORT && __('Short (< 4 minutes)')}
{dur === CS.DURATION_LONG && __('Long (> 20 min)')}
{dur === CS.DURATION_ALL && __('Any')}
</option>
))}
</FormField>
</div>
)}
{/* PAID FIELD */}
<div className={'claim-search__input-container'}>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected':
feeAmountParam === CS.FEE_AMOUNT_ONLY_FREE || feeAmountParam === CS.FEE_AMOUNT_ONLY_PAID,
})}
label={__('Price')}
type="select"
name="paidcontent"
value={feeAmountParam}
onChange={(e) =>
handleChange({
key: CS.FEE_AMOUNT_KEY,
value: e.target.value,
})
}
>
<option value={CS.FEE_AMOUNT_ANY}>{__('Any')}</option>
<option value={CS.FEE_AMOUNT_ONLY_FREE}>{__('Free')}</option>
<option value={CS.FEE_AMOUNT_ONLY_PAID}>{__('Paid')}</option>
))}
</FormField>
</div>
{/* SORT FIELD */}
{orderParam === CS.ORDER_BY_NEW && (
<div className={'claim-search__input-container'}>
<FormField
className={classnames('claim-search__dropdown', {
'claim-search__dropdown--selected': sortByParam,
})}
label={__('Sort By')}
type="select"
name="sort_by"
value={sortByParam || CS.SORT_BY.NEWEST.key}
onChange={(e) => handleChange({ key: CS.SORT_BY_KEY, value: e.target.value })}
>
{Object.entries(CS.SORT_BY).map(([key, value]) => {
return (
// $FlowFixMe https://github.com/facebook/flow/issues/2221
<option key={value.key} value={value.key}>
{/* $FlowFixMe */}
{__(value.str)}
</option>
);
})}
</FormField>
</div>
)}
{channelIdsInUrl && (
<div className={'claim-search__input-container'}>
<label>{__('Advanced Filters from URL')}</label>
<Button
button="alt"
className="claim-search__filter-button"
label={__('Clear')}
onClick={handleAdvancedReset}
/>
</div>
)}
</div>
{getHideRepostsElem(filterCtx, contentTypeParam)}
</>
)}
</div>
{hasMatureTags && hiddenNsfwMessage}
</>
);
}
export default ClaimListHeader;