Ability to customize homepage

Ticket: 1145

- Limited to premium for now (early access).
- Needed to handle custom categories from non-English homepages.
This commit is contained in:
infinite-persistence 2022-03-22 21:44:44 +08:00 committed by Thomas Zarebczan
parent d854a99250
commit 5b16b3b058
16 changed files with 495 additions and 106 deletions

View file

@ -2147,7 +2147,7 @@
"You will not see scheduled livestreams by people you follow on the home or following page.": "You will not see scheduled livestreams by people you follow on the home or following page.", "You will not see scheduled livestreams by people you follow on the home or following page.": "You will not see scheduled livestreams by people you follow on the home or following page.",
"Scheduled streams hidden, you can re-enable them in settings.": "Scheduled streams hidden, you can re-enable them in settings.", "Scheduled streams hidden, you can re-enable them in settings.": "Scheduled streams hidden, you can re-enable them in settings.",
"Hide Personal Recommendations": "Hide Personal Recommendations", "Hide Personal Recommendations": "Hide Personal Recommendations",
"Recommendations hidden; you can re-enable them in Settings.": "Recommendations hidden; you can re-enable them in Settings.", "Clean as a whistle! --[title for empty homepage]--": "Clean as a whistle!",
"You will not see the personal recommendations in the homepage.": "You will not see the personal recommendations in the homepage.", "You will not see the personal recommendations in the homepage.": "You will not see the personal recommendations in the homepage.",
"Sorry, the service is currently unavailable.": "Sorry, the service is currently unavailable.", "Sorry, the service is currently unavailable.": "Sorry, the service is currently unavailable.",
"Started %time_date%": "Started %time_date%", "Started %time_date%": "Started %time_date%",

View file

@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import HomepageSort from './view';
import * as SETTINGS from 'constants/settings';
import { selectClientSetting, selectHomepageData } from 'redux/selectors/settings';
const select = (state) => ({
homepageData: selectHomepageData(state),
homepageOrder: selectClientSetting(state, SETTINGS.HOMEPAGE_ORDER),
});
// const perform = (dispatch) => ({});
export default connect(select)(HomepageSort);

View file

@ -0,0 +1,179 @@
// @flow
import React, { useState } from 'react';
import classnames from 'classnames';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import 'scss/component/homepage-sort.scss';
// prettier-ignore
const Lazy = {
// $FlowFixMe: cannot resolve dnd
DragDropContext: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.DragDropContext }))),
// $FlowFixMe: cannot resolve dnd
Droppable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Droppable }))),
// $FlowFixMe: cannot resolve dnd
Draggable: React.lazy(() => import('react-beautiful-dnd' /* webpackChunkName: "dnd" */).then((module) => ({ default: module.Draggable }))),
};
const NON_CATEGORY = Object.freeze({
FOLLOWING: { label: 'Following' },
FYP: { label: 'Recommended' },
});
// ****************************************************************************
// Helpers
// ****************************************************************************
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const move = (source, destination, droppableSource, droppableDestination) => {
const sourceClone = Array.from(source);
const destClone = Array.from(destination);
const [removed] = sourceClone.splice(droppableSource.index, 1);
destClone.splice(droppableDestination.index, 0, removed);
return {
[droppableSource.droppableId]: sourceClone,
[droppableDestination.droppableId]: destClone,
};
};
function getInitialList(listId, savedOrder, homepageSections) {
const savedActiveOrder = savedOrder.active || [];
const savedHiddenOrder = savedOrder.hidden || [];
const sectionKeys = Object.keys(homepageSections);
if (listId === 'ACTIVE') {
// Start with saved order, excluding obsolete items (i.e. category removed or not available in non-English)
const finalOrder = savedActiveOrder.filter((x) => sectionKeys.includes(x));
// Add new items (e.g. new categories)
sectionKeys.forEach((x) => {
if (!finalOrder.includes(x)) {
finalOrder.push(x);
}
});
// Exclude items that were moved to Hidden
return finalOrder.filter((x) => !savedHiddenOrder.includes(x));
} else {
console.assert(listId === 'HIDDEN', `Unhandled listId: ${listId}`);
return savedHiddenOrder.filter((x) => sectionKeys.includes(x));
}
}
// ****************************************************************************
// HomepageSort
// ****************************************************************************
type HomepageOrder = { active: ?Array<string>, hidden: ?Array<string> };
type Props = {
onUpdate: (newOrder: HomepageOrder) => void,
// --- redux:
homepageData: any,
homepageOrder: HomepageOrder,
};
export default function HomepageSort(props: Props) {
const { onUpdate, homepageData, homepageOrder } = props;
const SECTIONS = { ...NON_CATEGORY, ...homepageData };
const [listActive, setListActive] = useState(() => getInitialList('ACTIVE', homepageOrder, SECTIONS));
const [listHidden, setListHidden] = useState(() => getInitialList('HIDDEN', homepageOrder, SECTIONS));
const BINS = {
ACTIVE: { id: 'ACTIVE', title: 'Active', list: listActive, setList: setListActive },
HIDDEN: { id: 'HIDDEN', title: 'Hidden', list: listHidden, setList: setListHidden },
};
function onDragEnd(result) {
const { source, destination } = result;
if (destination) {
if (source.droppableId === destination.droppableId) {
const newList = reorder(BINS[source.droppableId].list, source.index, destination.index);
BINS[source.droppableId].setList(newList);
} else {
const result = move(BINS[source.droppableId].list, BINS[destination.droppableId].list, source, destination);
BINS[source.droppableId].setList(result[source.droppableId]);
BINS[destination.droppableId].setList(result[destination.droppableId]);
}
}
}
const draggedItemRef = React.useRef();
const DraggableItem = ({ item, index }: any) => {
return (
<Lazy.Draggable draggableId={item} index={index}>
{(draggableProvided, snapshot) => {
if (snapshot.isDragging) {
// https://github.com/atlassian/react-beautiful-dnd/issues/1881#issuecomment-691237307
// $FlowFixMe
draggableProvided.draggableProps.style.left = draggedItemRef.offsetLeft;
// $FlowFixMe
draggableProvided.draggableProps.style.top = draggedItemRef.offsetTop;
}
return (
<div
className="homepage-sort__entry"
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
{...draggableProvided.dragHandleProps}
>
<div ref={draggedItemRef}>
<Icon icon={ICONS.MENU} title={__('Drag')} size={20} />
</div>
{__(SECTIONS[item].label)}
</div>
);
}}
</Lazy.Draggable>
);
};
const DroppableBin = ({ bin, className }: any) => {
return (
<Lazy.Droppable droppableId={bin.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={classnames('homepage-sort__bin', className, {
'homepage-sort__bin--highlight': snapshot.isDraggingOver,
})}
>
<div className="homepage-sort__bin-header">{__(bin.title)}</div>
{bin.list.map((item, index) => (
<DraggableItem key={item} item={item} index={index} />
))}
{provided.placeholder}
</div>
)}
</Lazy.Droppable>
);
};
React.useEffect(() => {
if (onUpdate) {
return onUpdate({ active: listActive, hidden: listHidden });
}
}, [listActive, listHidden, onUpdate]);
return (
<React.Suspense fallback={null}>
<div className="homepage-sort">
<Lazy.DragDropContext onDragEnd={onDragEnd}>
<DroppableBin bin={BINS.ACTIVE} />
<DroppableBin bin={BINS.HIDDEN} className="homepage-sort__bin--no-bg homepage-sort__bin--dashed" />
</Lazy.DragDropContext>
</div>
</React.Suspense>
);
}

View file

@ -1,10 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as SETTINGS from 'constants/settings';
import { doToast } from 'redux/actions/notifications';
import { doFetchPersonalRecommendations } from 'redux/actions/search'; import { doFetchPersonalRecommendations } from 'redux/actions/search';
import { doSetClientSetting } from 'redux/actions/settings';
import { selectPersonalRecommendations } from 'redux/selectors/search'; import { selectPersonalRecommendations } from 'redux/selectors/search';
import { selectClientSetting } from 'redux/selectors/settings';
import { selectHasOdyseeMembership, selectUser } from 'redux/selectors/user'; import { selectHasOdyseeMembership, selectUser } from 'redux/selectors/user';
import RecommendedPersonal from './view'; import RecommendedPersonal from './view';
@ -15,14 +11,11 @@ const select = (state) => {
userId: user && user.id, userId: user && user.id,
personalRecommendations: selectPersonalRecommendations(state), personalRecommendations: selectPersonalRecommendations(state),
hasMembership: selectHasOdyseeMembership(state), hasMembership: selectHasOdyseeMembership(state),
hideFyp: selectClientSetting(state, SETTINGS.HIDE_FYP),
}; };
}; };
const perform = { const perform = {
doFetchPersonalRecommendations, doFetchPersonalRecommendations,
doSetClientSetting,
doToast,
}; };
export default connect(select, perform)(RecommendedPersonal); export default connect(select, perform)(RecommendedPersonal);

View file

@ -1,44 +1,16 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import HelpLink from 'component/common/help-link';
import Icon from 'component/common/icon';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import { URL, SHARE_DOMAIN_URL } from 'config'; import I18nMessage from 'component/i18nMessage';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import * as SETTINGS from 'constants/settings';
import { useIsLargeScreen, useIsMediumScreen } from 'effects/use-screensize'; import { useIsLargeScreen, useIsMediumScreen } from 'effects/use-screensize';
// TODO: recsysFyp will be moved into 'RecSys', so the redux import in a jsx // TODO: recsysFyp will be moved into 'RecSys', so the redux import in a jsx
// violation is just temporary. // violation is just temporary.
import { recsysFyp } from 'redux/actions/search'; import { recsysFyp } from 'redux/actions/search';
// ****************************************************************************
// SectionHeader (TODO: DRY)
// ****************************************************************************
type SectionHeaderProps = {
title: string,
navigate?: string,
icon?: string,
help?: string,
onHide?: () => void,
};
const SectionHeader = ({ title, icon = '', help, onHide }: SectionHeaderProps) => {
const SHARE_DOMAIN = SHARE_DOMAIN_URL || URL;
return (
<h1 className="claim-grid__header">
<Icon className="claim-grid__header-icon" sectionIcon icon={icon} size={20} />
<span className="claim-grid__title">{title}</span>
{help}
<HelpLink href={`${SHARE_DOMAIN}/$/${PAGES.FYP}`} iconSize={24} description={__('Learn more')} />
<Button button="link" label={__('Hide')} onClick={onHide} className={'ml-m text-s'} />
</h1>
);
};
// **************************************************************************** // ****************************************************************************
// RecommendedPersonal // RecommendedPersonal
// **************************************************************************** // ****************************************************************************
@ -50,28 +22,17 @@ function getSuitablePageSizeForScreen(defaultSize, isLargeScreen, isMediumScreen
} }
type Props = { type Props = {
header: React$Node,
onLoad: (displayed: boolean) => void, onLoad: (displayed: boolean) => void,
// --- redux --- // --- redux ---
userId: ?string, userId: ?string,
personalRecommendations: { gid: string, uris: Array<string> }, personalRecommendations: { gid: string, uris: Array<string> },
hasMembership: boolean, hasMembership: boolean,
hideFyp: boolean,
doFetchPersonalRecommendations: () => void, doFetchPersonalRecommendations: () => void,
doSetClientSetting: (key: string, value: any, pushPreferences: boolean) => void,
doToast: ({ isError?: boolean, message: string }) => void,
}; };
export default function RecommendedPersonal(props: Props) { export default function RecommendedPersonal(props: Props) {
const { const { header, onLoad, userId, personalRecommendations, hasMembership, doFetchPersonalRecommendations } = props;
onLoad,
userId,
personalRecommendations,
hasMembership,
hideFyp,
doFetchPersonalRecommendations,
doSetClientSetting,
doToast,
} = props;
const ref = React.useRef(); const ref = React.useRef();
const [markedGid, setMarkedGid] = React.useState(''); const [markedGid, setMarkedGid] = React.useState('');
@ -83,11 +44,6 @@ export default function RecommendedPersonal(props: Props) {
const countCollapsed = getSuitablePageSizeForScreen(8, isLargeScreen, isMediumScreen); const countCollapsed = getSuitablePageSizeForScreen(8, isLargeScreen, isMediumScreen);
const finalCount = view === VIEW.ALL_VISIBLE ? count : view === VIEW.COLLAPSED ? countCollapsed : count; const finalCount = view === VIEW.ALL_VISIBLE ? count : view === VIEW.COLLAPSED ? countCollapsed : count;
function doHideFyp() {
doSetClientSetting(SETTINGS.HIDE_FYP, true, true);
doToast({ message: __('Recommendations hidden; you can re-enable them in Settings.') });
}
// ************************************************************************** // **************************************************************************
// Effects // Effects
// ************************************************************************** // **************************************************************************
@ -133,13 +89,39 @@ export default function RecommendedPersonal(props: Props) {
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
if (hideFyp || !hasMembership || count < 1) { if (!hasMembership) {
return null; return (
<div>
{header}
<div className="empty empty--centered-tight">
<I18nMessage
tokens={{ learn_more: <Button button="link" navigate={`/$/${PAGES.FYP}`} label={__('learn more')} /> }}
>
Premium membership required. Become a member, or %learn_more%.
</I18nMessage>
</div>
</div>
);
}
if (count < 1) {
return (
<div>
{header}
<div className="empty empty--centered-tight">
<I18nMessage
tokens={{ learn_more: <Button button="link" navigate={`/$/${PAGES.FYP}`} label={__('Learn More')} /> }}
>
No recommendations available at the moment. %learn_more%
</I18nMessage>
</div>
</div>
);
} }
return ( return (
<div ref={ref}> <div ref={ref}>
<SectionHeader title={__('Recommended For You')} icon={ICONS.WEB} onHide={doHideFyp} /> {header}
<ClaimList <ClaimList
tileLayout tileLayout
@ -162,8 +144,7 @@ export default function RecommendedPersonal(props: Props) {
if (ref.current) { if (ref.current) {
ref.current.scrollIntoView({ block: 'start', behavior: 'smooth' }); ref.current.scrollIntoView({ block: 'start', behavior: 'smooth' });
} else { } else {
// Unlikely to happen, but should have a fallback (just go all the way up) window.scrollTo({ top: 0, behavior: 'smooth' }); // fallback, unlikely.
window.scrollTo({ top: 0, behavior: 'smooth' });
} }
} }
}} }}

View file

@ -1,6 +1,7 @@
export const CONFIRM = 'confirm'; export const CONFIRM = 'confirm';
export const CONFIRM_FILE_REMOVE = 'confirm_file_remove'; export const CONFIRM_FILE_REMOVE = 'confirm_file_remove';
export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource'; export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource';
export const CUSTOMIZE_HOMEPAGE = 'customize_homepage';
export const INCOMPATIBLE_DAEMON = 'incompatible_daemon'; export const INCOMPATIBLE_DAEMON = 'incompatible_daemon';
export const FILE_TIMEOUT = 'file_timeout'; export const FILE_TIMEOUT = 'file_timeout';
export const FILE_SELECTION = 'file_selection'; export const FILE_SELECTION = 'file_selection';

View file

@ -34,6 +34,7 @@ export const TAGS_ACKNOWLEDGED = 'tags_acknowledged';
export const REWARDS_ACKNOWLEDGED = 'rewards_acknowledged'; export const REWARDS_ACKNOWLEDGED = 'rewards_acknowledged';
export const SEARCH_IN_LANGUAGE = 'search_in_language'; export const SEARCH_IN_LANGUAGE = 'search_in_language';
export const HOMEPAGE = 'homepage'; export const HOMEPAGE = 'homepage';
export const HOMEPAGE_ORDER = 'homepage_order';
export const HIDE_REPOSTS = 'hide_reposts'; export const HIDE_REPOSTS = 'hide_reposts';
export const HIDE_SCHEDULED_LIVESTREAMS = 'hide_scheduled_livestreams'; export const HIDE_SCHEDULED_LIVESTREAMS = 'hide_scheduled_livestreams';
export const HIDE_FYP = 'hide_fyp'; export const HIDE_FYP = 'hide_fyp';

View file

@ -31,4 +31,5 @@ export const CLIENT_SYNC_KEYS = [
SETTINGS.DARK_MODE_TIMES, SETTINGS.DARK_MODE_TIMES,
SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED,
SETTINGS.LANGUAGE, SETTINGS.LANGUAGE,
SETTINGS.HOMEPAGE_ORDER,
]; ];

View file

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import ModalCustomizeHomepage from './view';
import * as SETTINGS from 'constants/settings';
import { doHideModal } from 'redux/actions/app';
import { doSetClientSetting } from 'redux/actions/settings';
import { selectClientSetting } from 'redux/selectors/settings';
import { selectHasOdyseeMembership } from 'redux/selectors/user';
const select = (state) => ({
hasMembership: selectHasOdyseeMembership(state),
homepageOrder: selectClientSetting(state, SETTINGS.HOMEPAGE_ORDER),
});
const perform = {
doSetClientSetting,
doHideModal,
};
export default connect(select, perform)(ModalCustomizeHomepage);

View file

@ -0,0 +1,93 @@
// @flow
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import HomepageSort from 'component/homepageSort';
import MembershipSplash from 'component/membershipSplash';
import * as SETTINGS from 'constants/settings';
import { Modal } from 'modal/modal';
type HomepageOrder = { active: ?Array<string>, hidden: ?Array<string> };
type Props = {
hasMembership: ?boolean,
homepageOrder: HomepageOrder,
doSetClientSetting: (key: string, value: any, push: boolean) => void,
doHideModal: () => void,
};
export default function ModalCustomizeHomepage(props: Props) {
const { hasMembership, homepageOrder, doSetClientSetting, doHideModal } = props;
const order = React.useRef();
function handleNewOrder(newOrder: HomepageOrder) {
order.current = newOrder;
}
function handleSave() {
// Non-English homepages created their own categories, so that made things
// complicated. Store every new key encountered, and just not show them
// in the GUI depending on the selected homepage language.
// Be sure not to erase any saved keys.
if (order.current) {
const orderToSave: HomepageOrder = order.current;
// ** Note: the forEach() is probably masking Flow from seeing that null active/hidden is already handled.
if (orderToSave.active && orderToSave.hidden) {
if (homepageOrder.active) {
homepageOrder.active.forEach((x) => {
// $FlowFixMe: **
if (!orderToSave.active.includes(x) && !orderToSave.hidden.includes(x)) {
// $FlowFixMe: **
orderToSave.active.push(x);
}
});
}
if (homepageOrder.hidden) {
homepageOrder.hidden.forEach((x) => {
// $FlowFixMe: **
if (!orderToSave.active.includes(x) && !orderToSave.hidden.includes(x)) {
// $FlowFixMe: **
orderToSave.hidden.push(x);
}
});
}
doSetClientSetting(SETTINGS.HOMEPAGE_ORDER, orderToSave, true);
} else {
console.error('Homepage: invalid orderToSave', orderToSave); // eslint-disable-line no-console
}
}
doHideModal();
}
return (
<Modal isOpen type={hasMembership ? 'custom' : 'card'} onAborted={hasMembership ? undefined : doHideModal}>
{!hasMembership && (
<Card
title={__('Customize Homepage')}
subtitle={__('This is currently an early-access feature for Premium members.')}
body={
<div className="card__main-actions">
<MembershipSplash pageLocation={'confirmPage'} currencyToUse={'usd'} />
</div>
}
/>
)}
{hasMembership && (
<Card
title={__('Customize Homepage')}
body={<HomepageSort onUpdate={handleNewOrder} />}
actions={
<div className="section__actions">
<Button button="primary" label={__('Save')} onClick={handleSave} />
<Button button="link" label={__('Cancel')} onClick={doHideModal} />
</div>
}
/>
)}
</Modal>
);
}

View file

@ -23,6 +23,7 @@ const MAP = Object.freeze({
[MODALS.CONFIRM_REMOVE_COMMENT]: lazyImport(() => import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)), [MODALS.CONFIRM_REMOVE_COMMENT]: lazyImport(() => import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)),
[MODALS.CONFIRM_THUMBNAIL_UPLOAD]: lazyImport(() => import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */)), [MODALS.CONFIRM_THUMBNAIL_UPLOAD]: lazyImport(() => import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */)),
[MODALS.CONFIRM_TRANSACTION]: lazyImport(() => import('modal/modalConfirmTransaction' /* webpackChunkName: "modalConfirmTransaction" */)), [MODALS.CONFIRM_TRANSACTION]: lazyImport(() => import('modal/modalConfirmTransaction' /* webpackChunkName: "modalConfirmTransaction" */)),
[MODALS.CUSTOMIZE_HOMEPAGE]: lazyImport(() => import('modal/modalCustomizeHomepage' /* webpackChunkName: "modalCustomizeHomepage" */)),
[MODALS.DOWNLOADING]: lazyImport(() => import('modal/modalDownloading' /* webpackChunkName: "modalDownloading" */)), [MODALS.DOWNLOADING]: lazyImport(() => import('modal/modalDownloading' /* webpackChunkName: "modalDownloading" */)),
[MODALS.ERROR]: lazyImport(() => import('modal/modalError' /* webpackChunkName: "modalError" */)), [MODALS.ERROR]: lazyImport(() => import('modal/modalError' /* webpackChunkName: "modalError" */)),
[MODALS.FILE_SELECTION]: lazyImport(() => import('modal/modalFileSelection' /* webpackChunkName: "modalFileSelection" */)), [MODALS.FILE_SELECTION]: lazyImport(() => import('modal/modalFileSelection' /* webpackChunkName: "modalFileSelection" */)),

View file

@ -1,5 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { doOpenModal } from 'redux/actions/app';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectAdBlockerFound } from 'redux/selectors/app'; import { selectAdBlockerFound } from 'redux/selectors/app';
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream'; import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
@ -20,10 +21,12 @@ const select = (state) => ({
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state), fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS), hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS),
adBlockerFound: selectAdBlockerFound(state), adBlockerFound: selectAdBlockerFound(state),
homepageOrder: selectClientSetting(state, SETTINGS.HOMEPAGE_ORDER),
}); });
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doFetchActiveLivestreams: () => dispatch(doFetchActiveLivestreams()), doFetchActiveLivestreams: () => dispatch(doFetchActiveLivestreams()),
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
}); });
export default connect(select, perform)(DiscoverPage); export default connect(select, perform)(DiscoverPage);

View file

@ -1,7 +1,8 @@
// @flow // @flow
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import { SITE_NAME, SIMPLE_SITE, ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import React from 'react'; import React from 'react';
import Page from 'component/page'; import Page from 'component/page';
import Button from 'component/button'; import Button from 'component/button';
@ -10,6 +11,7 @@ import ClaimPreviewTile from 'component/claimPreviewTile';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import WaitUntilOnPage from 'component/common/wait-until-on-page'; import WaitUntilOnPage from 'component/common/wait-until-on-page';
import RecommendedPersonal from 'component/recommendedPersonal'; import RecommendedPersonal from 'component/recommendedPersonal';
import Yrbl from 'component/yrbl';
import { useIsLargeScreen } from 'effects/use-screensize'; import { useIsLargeScreen } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage'; import { GetLinksData } from 'util/buildHomepage';
import { getLivestreamUris } from 'util/livestream'; import { getLivestreamUris } from 'util/livestream';
@ -17,15 +19,14 @@ import ScheduledStreams from 'component/scheduledStreams';
import { splitBySeparator } from 'util/lbryURI'; import { splitBySeparator } from 'util/lbryURI';
import classnames from 'classnames'; import classnames from 'classnames';
import Ads from 'web/component/ads'; import Ads from 'web/component/ads';
// @if TARGET='web'
import Meme from 'web/component/meme'; import Meme from 'web/component/meme';
// @endif
function resolveTitleOverride(title: string) { function resolveTitleOverride(title: string) {
return title === 'Recent From Following' ? 'Following' : title; return title === 'Recent From Following' ? 'Following' : title;
} }
type HomepageOrder = { active: ?Array<string>, hidden: ?Array<string> };
type Props = { type Props = {
authenticated: boolean, authenticated: boolean,
followedTags: Array<Tag>, followedTags: Array<Tag>,
@ -37,6 +38,8 @@ type Props = {
fetchingActiveLivestreams: boolean, fetchingActiveLivestreams: boolean,
hideScheduledLivestreams: boolean, hideScheduledLivestreams: boolean,
adBlockerFound: ?boolean, adBlockerFound: ?boolean,
homepageOrder: HomepageOrder,
doOpenModal: (id: string, ?{}) => void,
}; };
function HomePage(props: Props) { function HomePage(props: Props) {
@ -51,6 +54,8 @@ function HomePage(props: Props) {
fetchingActiveLivestreams, fetchingActiveLivestreams,
hideScheduledLivestreams, hideScheduledLivestreams,
adBlockerFound, adBlockerFound,
homepageOrder,
doOpenModal,
} = props; } = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0; const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
@ -72,6 +77,26 @@ function HomePage(props: Props) {
showNsfw showNsfw
); );
// TODO: probably need memo, or incorporate into GetLinksData.
let sortedRowData: Array<RowDataItem> = [];
if (homepageOrder.active) {
homepageOrder.active.forEach((key) => {
const item = rowData.find((data) => data.id === key);
if (item) {
sortedRowData.push(item);
} else if (key === 'FYP') {
sortedRowData.push({
id: 'FYP',
title: 'Recommended For You',
icon: ICONS.GLOBE,
link: `/$/${PAGES.FYP}`,
});
}
});
} else {
sortedRowData = rowData;
}
type SectionHeaderProps = { type SectionHeaderProps = {
title: string, title: string,
navigate?: string, navigate?: string,
@ -91,7 +116,19 @@ function HomePage(props: Props) {
); );
}; };
function getRowElements(title, route, link, icon, help, options, index, pinUrls) { const CustomizeHomepage = () => {
return (
<Button
button="link"
iconRight={ICONS.SETTINGS}
onClick={() => doOpenModal(MODALS.CUSTOMIZE_HOMEPAGE)}
title={__('Sort and customize your homepage')}
label={__('Customize --[Short label for "Customize Homepage"]--')}
/>
);
};
function getRowElements(id, title, route, link, icon, help, options, index, pinUrls) {
const tilePlaceholder = ( const tilePlaceholder = (
<ul className="claim-grid"> <ul className="claim-grid">
{new Array(options.pageSize || 8).fill(1).map((x, i) => ( {new Array(options.pageSize || 8).fill(1).map((x, i) => (
@ -116,34 +153,48 @@ function HomePage(props: Props) {
/> />
); );
const isFollowingSection = link === `/$/${PAGES.CHANNELS_FOLLOWING}`; const HeaderArea = () => {
return (
<>
{title && typeof title === 'string' && (
<div className="homePage-wrapper__section-title">
<SectionHeader title={__(resolveTitleOverride(title))} navigate={route || link} icon={icon} help={help} />
{index === 0 && <CustomizeHomepage />}
</div>
)}
</>
);
};
return ( return (
<div <div
key={title} key={title}
className={classnames('claim-grid__wrapper', { className={classnames('claim-grid__wrapper', {
'hide-ribbon': !isFollowingSection, 'hide-ribbon': link !== `/$/${PAGES.CHANNELS_FOLLOWING}`,
})} })}
> >
{title && typeof title === 'string' && ( {id === 'FYP' ? (
<SectionHeader title={__(resolveTitleOverride(title))} navigate={route || link} icon={icon} help={help} /> <RecommendedPersonal header={<HeaderArea />} />
) : (
<>
<HeaderArea />
{index === 0 && <>{claimTiles}</>}
{index !== 0 && (
<WaitUntilOnPage name={title} placeholder={tilePlaceholder} yOffset={800}>
{claimTiles}
</WaitUntilOnPage>
)}
{(route || link) && (
<Button
className="claim-grid__title--secondary"
button="link"
navigate={route || link}
iconRight={ICONS.ARROW_RIGHT}
label={__('View More')}
/>
)}
</>
)} )}
{index === 0 && <>{claimTiles}</>}
{index !== 0 && (
<WaitUntilOnPage name={title} placeholder={tilePlaceholder} yOffset={800}>
{claimTiles}
</WaitUntilOnPage>
)}
{(route || link) && (
<Button
className="claim-grid__title--secondary"
button="link"
navigate={route || link}
iconRight={ICONS.ARROW_RIGHT}
label={__('View More')}
/>
)}
{isFollowingSection && <RecommendedPersonal />}
</div> </div>
); );
} }
@ -154,24 +205,7 @@ function HomePage(props: Props) {
return ( return (
<Page className="homePage-wrapper" fullWidthPage> <Page className="homePage-wrapper" fullWidthPage>
{!SIMPLE_SITE && (authenticated || !IS_WEB) && !subscribedChannels.length && ( <Meme />
<div className="notice-message">
<h1 className="section__title">
{__("%SITE_NAME% is more fun if you're following channels", { SITE_NAME })}
</h1>
<p className="section__actions">
<Button
button="primary"
navigate={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
label={__('Find new channels to follow')}
/>
</p>
</div>
)}
{/* @if TARGET='web' */}
{SIMPLE_SITE && <Meme />}
{/* @endif */}
{!fetchingActiveLivestreams && ( {!fetchingActiveLivestreams && (
<> <>
@ -186,9 +220,18 @@ function HomePage(props: Props) {
</> </>
)} )}
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => { {sortedRowData.length === 0 && (
// add pins here <div className="empty--centered">
return getRowElements(title, route, link, icon, help, options, index, pinUrls); <Yrbl
alwaysShow
title={__('Clean as a whistle! --[title for empty homepage]--')}
actions={<CustomizeHomepage />}
/>
</div>
)}
{sortedRowData.map(({ id, title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
return getRowElements(id, title, route, link, icon, help, options, index, pinUrls);
})} })}
</Page> </Page>
); );

View file

@ -43,6 +43,7 @@ const defaultState = {
[SETTINGS.THEME]: __('dark'), [SETTINGS.THEME]: __('dark'),
[SETTINGS.THEMES]: [__('dark'), __('light')], [SETTINGS.THEMES]: [__('dark'), __('light')],
[SETTINGS.HOMEPAGE]: null, [SETTINGS.HOMEPAGE]: null,
[SETTINGS.HOMEPAGE_ORDER]: { active: null, hidden: null },
[SETTINGS.HIDE_SPLASH_ANIMATION]: false, [SETTINGS.HIDE_SPLASH_ANIMATION]: false,
[SETTINGS.HIDE_BALANCE]: false, [SETTINGS.HIDE_BALANCE]: false,
[SETTINGS.OS_NOTIFICATIONS_ENABLED]: true, [SETTINGS.OS_NOTIFICATIONS_ENABLED]: true,

View file

@ -0,0 +1,55 @@
.homepage-sort {
display: flex;
margin-bottom: var(--spacing-m);
height: 50vh;
}
.homepage-sort__bin {
width: 50%;
background-color: var(--color-ads-background);
padding: var(--spacing-s);
margin-right: var(--spacing-s);
border: 1px solid var(--color-border);
overflow: auto;
overflow-x: hidden;
}
.homepage-sort__bin--dashed {
border: dashed var(--color-border);
}
.homepage-sort__bin--highlight {
border-color: var(--color-primary);
border-width: 2px;
}
.homepage-sort__bin--no-bg {
background-color: unset;
}
.homepage-sort__bin-header {
text-align: center;
margin-bottom: var(--spacing-s);
color: var(--color-text-subtitle);
}
.homepage-sort__entry {
display: flex;
width: 100%;
border: 1px solid grey;
margin-bottom: var(--spacing-m);
background-color: var(--color-card-background);
padding: var(--spacing-xs) var(--spacing-m) var(--spacing-xs) var(--spacing-m);
align-items: center;
font-size: var(--font-small);
svg {
margin-top: var(--spacing-xxs);
margin-right: var(--spacing-s);
opacity: 0.4;
}
}
.homepage-sort__entry:last-child {
margin-bottom: 0;
}

View file

@ -623,6 +623,11 @@ img {
} }
} }
.homePage-wrapper__section-title {
display: flex;
justify-content: space-between;
}
// The following wrapper classes are temporary to fix page specific issues // The following wrapper classes are temporary to fix page specific issues
.discoverPage-wrapper { .discoverPage-wrapper {
.claim-preview__wrapper { .claim-preview__wrapper {