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:
parent
d854a99250
commit
5b16b3b058
16 changed files with 495 additions and 106 deletions
|
@ -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%",
|
||||
|
|
13
ui/component/homepageSort/index.js
Normal file
13
ui/component/homepageSort/index.js
Normal 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);
|
179
ui/component/homepageSort/view.jsx
Normal file
179
ui/component/homepageSort/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -31,4 +31,5 @@ export const CLIENT_SYNC_KEYS = [
|
|||
SETTINGS.DARK_MODE_TIMES,
|
||||
SETTINGS.AUTOMATIC_DARK_MODE_ENABLED,
|
||||
SETTINGS.LANGUAGE,
|
||||
SETTINGS.HOMEPAGE_ORDER,
|
||||
];
|
||||
|
|
19
ui/modal/modalCustomizeHomepage/index.js
Normal file
19
ui/modal/modalCustomizeHomepage/index.js
Normal 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);
|
93
ui/modal/modalCustomizeHomepage/view.jsx
Normal file
93
ui/modal/modalCustomizeHomepage/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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" */)),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
55
ui/scss/component/homepage-sort.scss
Normal file
55
ui/scss/component/homepage-sort.scss
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue