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.",
"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",
"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.",
"Sorry, the service is currently unavailable.": "Sorry, the service is currently unavailable.",
"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 * as SETTINGS from 'constants/settings';
import { doToast } from 'redux/actions/notifications';
import { doFetchPersonalRecommendations } from 'redux/actions/search';
import { doSetClientSetting } from 'redux/actions/settings';
import { selectPersonalRecommendations } from 'redux/selectors/search';
import { selectClientSetting } from 'redux/selectors/settings';
import { selectHasOdyseeMembership, selectUser } from 'redux/selectors/user';
import RecommendedPersonal from './view';
@ -15,14 +11,11 @@ const select = (state) => {
userId: user && user.id,
personalRecommendations: selectPersonalRecommendations(state),
hasMembership: selectHasOdyseeMembership(state),
hideFyp: selectClientSetting(state, SETTINGS.HIDE_FYP),
};
};
const perform = {
doFetchPersonalRecommendations,
doSetClientSetting,
doToast,
};
export default connect(select, perform)(RecommendedPersonal);

View file

@ -1,44 +1,16 @@
// @flow
import React from 'react';
import Button from 'component/button';
import HelpLink from 'component/common/help-link';
import Icon from 'component/common/icon';
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 PAGES from 'constants/pages';
import * as SETTINGS from 'constants/settings';
import { useIsLargeScreen, useIsMediumScreen } from 'effects/use-screensize';
// TODO: recsysFyp will be moved into 'RecSys', so the redux import in a jsx
// violation is just temporary.
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
// ****************************************************************************
@ -50,28 +22,17 @@ function getSuitablePageSizeForScreen(defaultSize, isLargeScreen, isMediumScreen
}
type Props = {
header: React$Node,
onLoad: (displayed: boolean) => void,
// --- redux ---
userId: ?string,
personalRecommendations: { gid: string, uris: Array<string> },
hasMembership: boolean,
hideFyp: boolean,
doFetchPersonalRecommendations: () => void,
doSetClientSetting: (key: string, value: any, pushPreferences: boolean) => void,
doToast: ({ isError?: boolean, message: string }) => void,
};
export default function RecommendedPersonal(props: Props) {
const {
onLoad,
userId,
personalRecommendations,
hasMembership,
hideFyp,
doFetchPersonalRecommendations,
doSetClientSetting,
doToast,
} = props;
const { header, onLoad, userId, personalRecommendations, hasMembership, doFetchPersonalRecommendations } = props;
const ref = React.useRef();
const [markedGid, setMarkedGid] = React.useState('');
@ -83,11 +44,6 @@ export default function RecommendedPersonal(props: Props) {
const countCollapsed = getSuitablePageSizeForScreen(8, isLargeScreen, isMediumScreen);
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
// **************************************************************************
@ -133,13 +89,39 @@ export default function RecommendedPersonal(props: Props) {
// **************************************************************************
// **************************************************************************
if (hideFyp || !hasMembership || count < 1) {
return null;
if (!hasMembership) {
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 (
<div ref={ref}>
<SectionHeader title={__('Recommended For You')} icon={ICONS.WEB} onHide={doHideFyp} />
{header}
<ClaimList
tileLayout
@ -162,8 +144,7 @@ export default function RecommendedPersonal(props: Props) {
if (ref.current) {
ref.current.scrollIntoView({ block: 'start', behavior: 'smooth' });
} else {
// Unlikely to happen, but should have a fallback (just go all the way up)
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: 'smooth' }); // fallback, unlikely.
}
}
}}

View file

@ -1,6 +1,7 @@
export const CONFIRM = 'confirm';
export const CONFIRM_FILE_REMOVE = 'confirm_file_remove';
export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource';
export const CUSTOMIZE_HOMEPAGE = 'customize_homepage';
export const INCOMPATIBLE_DAEMON = 'incompatible_daemon';
export const FILE_TIMEOUT = 'file_timeout';
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 SEARCH_IN_LANGUAGE = 'search_in_language';
export const HOMEPAGE = 'homepage';
export const HOMEPAGE_ORDER = 'homepage_order';
export const HIDE_REPOSTS = 'hide_reposts';
export const HIDE_SCHEDULED_LIVESTREAMS = 'hide_scheduled_livestreams';
export const HIDE_FYP = 'hide_fyp';

View file

@ -31,4 +31,5 @@ export const CLIENT_SYNC_KEYS = [
SETTINGS.DARK_MODE_TIMES,
SETTINGS.AUTOMATIC_DARK_MODE_ENABLED,
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_THUMBNAIL_UPLOAD]: lazyImport(() => import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */)),
[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.ERROR]: lazyImport(() => import('modal/modalError' /* webpackChunkName: "modalError" */)),
[MODALS.FILE_SELECTION]: lazyImport(() => import('modal/modalFileSelection' /* webpackChunkName: "modalFileSelection" */)),

View file

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

View file

@ -1,7 +1,8 @@
// @flow
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
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 Page from 'component/page';
import Button from 'component/button';
@ -10,6 +11,7 @@ import ClaimPreviewTile from 'component/claimPreviewTile';
import Icon from 'component/common/icon';
import WaitUntilOnPage from 'component/common/wait-until-on-page';
import RecommendedPersonal from 'component/recommendedPersonal';
import Yrbl from 'component/yrbl';
import { useIsLargeScreen } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage';
import { getLivestreamUris } from 'util/livestream';
@ -17,15 +19,14 @@ import ScheduledStreams from 'component/scheduledStreams';
import { splitBySeparator } from 'util/lbryURI';
import classnames from 'classnames';
import Ads from 'web/component/ads';
// @if TARGET='web'
import Meme from 'web/component/meme';
// @endif
function resolveTitleOverride(title: string) {
return title === 'Recent From Following' ? 'Following' : title;
}
type HomepageOrder = { active: ?Array<string>, hidden: ?Array<string> };
type Props = {
authenticated: boolean,
followedTags: Array<Tag>,
@ -37,6 +38,8 @@ type Props = {
fetchingActiveLivestreams: boolean,
hideScheduledLivestreams: boolean,
adBlockerFound: ?boolean,
homepageOrder: HomepageOrder,
doOpenModal: (id: string, ?{}) => void,
};
function HomePage(props: Props) {
@ -51,6 +54,8 @@ function HomePage(props: Props) {
fetchingActiveLivestreams,
hideScheduledLivestreams,
adBlockerFound,
homepageOrder,
doOpenModal,
} = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
@ -72,6 +77,26 @@ function HomePage(props: Props) {
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 = {
title: 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 = (
<ul className="claim-grid">
{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 (
<div
key={title}
className={classnames('claim-grid__wrapper', {
'hide-ribbon': !isFollowingSection,
'hide-ribbon': link !== `/$/${PAGES.CHANNELS_FOLLOWING}`,
})}
>
{title && typeof title === 'string' && (
<SectionHeader title={__(resolveTitleOverride(title))} navigate={route || link} icon={icon} help={help} />
{id === 'FYP' ? (
<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>
);
}
@ -154,24 +205,7 @@ function HomePage(props: Props) {
return (
<Page className="homePage-wrapper" fullWidthPage>
{!SIMPLE_SITE && (authenticated || !IS_WEB) && !subscribedChannels.length && (
<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 */}
<Meme />
{!fetchingActiveLivestreams && (
<>
@ -186,9 +220,18 @@ function HomePage(props: Props) {
</>
)}
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
// add pins here
return getRowElements(title, route, link, icon, help, options, index, pinUrls);
{sortedRowData.length === 0 && (
<div className="empty--centered">
<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>
);

View file

@ -43,6 +43,7 @@ const defaultState = {
[SETTINGS.THEME]: __('dark'),
[SETTINGS.THEMES]: [__('dark'), __('light')],
[SETTINGS.HOMEPAGE]: null,
[SETTINGS.HOMEPAGE_ORDER]: { active: null, hidden: null },
[SETTINGS.HIDE_SPLASH_ANIMATION]: false,
[SETTINGS.HIDE_BALANCE]: false,
[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
.discoverPage-wrapper {
.claim-preview__wrapper {