Merge pull request #2109 from lbryio/subscriptions

Show recommended subscriptions
This commit is contained in:
Sean Yesmunt 2018-11-27 11:23:38 -05:00 committed by GitHub
commit 1dd6a9e42f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 596 additions and 189 deletions

View file

@ -44,6 +44,7 @@
"jsx-a11y/interactive-supports-focus": 0, "jsx-a11y/interactive-supports-focus": 0,
"jsx-a11y/click-events-have-key-events": 0, "jsx-a11y/click-events-have-key-events": 0,
"consistent-return": 0, "consistent-return": 0,
"no-prototype-builtins": 0,
"flowtype/space-after-type-colon": [ 2, "always", { "allowLineBreak": true } ] "flowtype/space-after-type-colon": [ 2, "always", { "allowLineBreak": true } ]
} }
} }

View file

@ -6,7 +6,7 @@ import ReactModal from 'react-modal';
import throttle from 'util/throttle'; import throttle from 'util/throttle';
import SideBar from 'component/sideBar'; import SideBar from 'component/sideBar';
import Header from 'component/header'; import Header from 'component/header';
import { openContextMenu } from '../../util/contextMenu'; import { openContextMenu } from '../../util/context-menu';
const TWO_POINT_FIVE_MINUTES = 1000 * 60 * 2.5; const TWO_POINT_FIVE_MINUTES = 1000 * 60 * 2.5;

View file

@ -1,18 +1,19 @@
// @flow // @flow
import * as React from 'react'; import type { Claim } from 'types/claim';
import React, { PureComponent } from 'react';
import { normalizeURI } from 'lbry-redux'; import { normalizeURI } from 'lbry-redux';
import ToolTip from 'component/common/tooltip'; import ToolTip from 'component/common/tooltip';
import FileCard from 'component/fileCard'; import FileCard from 'component/fileCard';
import Button from 'component/button'; import Button from 'component/button';
import * as icons from 'constants/icons'; import * as icons from 'constants/icons';
import type { Claim } from 'types/claim'; import SubscribeButton from 'component/subscribeButton';
type Props = { type Props = {
category: string, category: string,
names: Array<string>, names: ?Array<string>,
categoryLink: ?string, categoryLink: ?string,
fetching: boolean, fetching: boolean,
channelClaims: Array<Claim>, channelClaims: ?Array<Claim>,
fetchChannel: string => void, fetchChannel: string => void,
obscureNsfw: boolean, obscureNsfw: boolean,
}; };
@ -22,9 +23,8 @@ type State = {
canScrollPrevious: boolean, canScrollPrevious: boolean,
}; };
class CategoryList extends React.PureComponent<Props, State> { class CategoryList extends PureComponent<Props, State> {
static defaultProps = { static defaultProps = {
names: [],
categoryLink: '', categoryLink: '',
}; };
@ -209,7 +209,6 @@ class CategoryList extends React.PureComponent<Props, State> {
render() { render() {
const { category, categoryLink, names, channelClaims, obscureNsfw } = this.props; const { category, categoryLink, names, channelClaims, obscureNsfw } = this.props;
const { canScrollNext, canScrollPrevious } = this.state; const { canScrollNext, canScrollPrevious } = this.state;
const isCommunityTopBids = category.match(/^community/i); const isCommunityTopBids = category.match(/^community/i);
const showScrollButtons = isCommunityTopBids ? !obscureNsfw : true; const showScrollButtons = isCommunityTopBids ? !obscureNsfw : true;
@ -218,7 +217,10 @@ class CategoryList extends React.PureComponent<Props, State> {
<div className="card-row__header"> <div className="card-row__header">
<div className="card-row__title"> <div className="card-row__title">
{categoryLink ? ( {categoryLink ? (
<Button label={category} navigate="/show" navigateParams={{ uri: categoryLink }} /> <div className="card__actions card__actions--no-margin">
<Button label={category} navigate="/show" navigateParams={{ uri: categoryLink }} />
<SubscribeButton uri={`lbry://${categoryLink}`} showSnackBarOnSubscribe />
</div>
) : ( ) : (
category category
)} )}
@ -263,19 +265,33 @@ class CategoryList extends React.PureComponent<Props, State> {
}} }}
> >
{names && {names &&
names.length &&
names.map(name => ( names.map(name => (
<FileCard showSubscribedLogo key={name} uri={normalizeURI(name)} /> <FileCard showSubscribedLogo key={name} uri={normalizeURI(name)} />
))} ))}
{channelClaims && {channelClaims &&
channelClaims.length && channelClaims.length &&
channelClaims.map(claim => ( channelClaims
<FileCard // Only show the first 10 claims, regardless of the amount we have on a channel page
showSubcribedLogo .slice(0, 10)
key={claim.claim_id} .map(claim => (
uri={`lbry://${claim.name}#${claim.claim_id}`} <FileCard
/> showSubcribedLogo
))} key={claim.claim_id}
uri={`lbry://${claim.name}#${claim.claim_id}`}
/>
))}
{/*
If there aren't any uris passed in, create an empty array and render placeholder cards
channelClaims or names are being fetched
*/}
{!channelClaims &&
!names &&
/* eslint-disable react/no-array-index-key */
new Array(10).fill(1).map((x, i) => <FileCard key={i} />)
/* eslint-enable react/no-array-index-key */
}
</div> </div>
)} )}
</div> </div>

View file

@ -76,7 +76,7 @@ class ChannelTile extends React.PureComponent<Props> {
)} )}
{subscriptionUri && ( {subscriptionUri && (
<div className="card__actions"> <div className="card__actions">
<SubscribeButton uri={subscriptionUri} channelName={channelName} /> <SubscribeButton uri={subscriptionUri} />
</div> </div>
)} )}
</div> </div>

View file

@ -1,7 +1,7 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { formatCredits, formatFullPrice } from 'util/formatCredits'; import { formatCredits, formatFullPrice } from 'util/format-credits';
type Props = { type Props = {
amount: number, amount: number,

View file

@ -3,7 +3,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import React from 'react'; import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import parseData from 'util/parseData'; import parseData from 'util/parse-data';
import * as icons from 'constants/icons'; import * as icons from 'constants/icons';
import { remote } from 'electron'; import { remote } from 'electron';
@ -33,7 +33,10 @@ class FileExporter extends React.PureComponent<Props> {
fs.writeFile(filename, data, err => { fs.writeFile(filename, data, err => {
if (err) throw err; if (err) throw err;
// Do something after creation // Do something after creation
onFileCreated && onFileCreated(filename);
if (onFileCreated) {
onFileCreated(filename);
}
}); });
} }
@ -55,24 +58,22 @@ class FileExporter extends React.PureComponent<Props> {
], ],
}; };
remote.dialog.showSaveDialog( remote.dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => {
remote.getCurrentWindow(), // User hit cancel so do nothing:
options, if (!filename) return;
filename => { // Get extension and remove initial dot
// User hit cancel so do nothing: const format = path.extname(filename).replace(/\./g, '');
if (!filename) return; // Parse data to string with the chosen format
// Get extension and remove initial dot const parsed = parseData(data, format, filters);
const format = path.extname(filename).replace(/\./g, ''); // Write file
// Parse data to string with the chosen format if (parsed) {
const parsed = parseData(data, format, filters); this.handleFileCreation(filename, parsed);
// Write file
parsed && this.handleFileCreation(filename, parsed);
} }
); });
} }
render() { render() {
const { title, label } = this.props; const { label } = this.props;
return ( return (
<Button <Button
button="primary" button="primary"

View file

@ -6,7 +6,7 @@ import MarkdownPreview from 'component/common/markdown-preview';
import SimpleMDE from 'react-simplemde-editor'; import SimpleMDE from 'react-simplemde-editor';
import 'simplemde/dist/simplemde.min.css'; // eslint-disable-line import/no-extraneous-dependencies import 'simplemde/dist/simplemde.min.css'; // eslint-disable-line import/no-extraneous-dependencies
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import { openEditorMenu, stopContextMenu } from 'util/contextMenu'; import { openEditorMenu, stopContextMenu } from 'util/context-menu';
type Props = { type Props = {
name: string, name: string,

View file

@ -9,7 +9,7 @@ import UriIndicator from 'component/uriIndicator';
import * as icons from 'constants/icons'; import * as icons from 'constants/icons';
import classnames from 'classnames'; import classnames from 'classnames';
import FilePrice from 'component/filePrice'; import FilePrice from 'component/filePrice';
import { openCopyLinkMenu } from 'util/contextMenu'; import { openCopyLinkMenu } from 'util/context-menu';
import DateTime from 'component/dateTime'; import DateTime from 'component/dateTime';
type Props = { type Props = {

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectBalance, selectIsBackDisabled, selectIsForwardDisabled } from 'lbry-redux'; import { selectBalance, selectIsBackDisabled, selectIsForwardDisabled } from 'lbry-redux';
import { formatCredits } from 'util/formatCredits'; import { formatCredits } from 'util/format-credits';
import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation'; import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation';
import { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app'; import { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app';
import { doDownloadUpgradeRequested } from 'redux/actions/app'; import { doDownloadUpgradeRequested } from 'redux/actions/app';

View file

@ -8,7 +8,7 @@ import {
import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation'; import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation';
import { doDownloadUpgrade } from 'redux/actions/app'; import { doDownloadUpgrade } from 'redux/actions/app';
import { selectIsUpgradeAvailable, selectNavLinks } from 'redux/selectors/app'; import { selectIsUpgradeAvailable, selectNavLinks } from 'redux/selectors/app';
import { formatCredits } from 'util/formatCredits'; import { formatCredits } from 'util/format-credits';
import Page from './view'; import Page from './view';
const select = state => ({ const select = state => ({

View file

@ -1,12 +1,18 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions'; import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import { selectSubscriptions, makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; import {
selectSubscriptions,
makeSelectIsSubscribed,
selectFirstRunCompleted,
} from 'redux/selectors/subscriptions';
import { doToast } from 'lbry-redux';
import SubscribeButton from './view'; import SubscribeButton from './view';
const select = (state, props) => ({ const select = (state, props) => ({
subscriptions: selectSubscriptions(state), subscriptions: selectSubscriptions(state),
isSubscribed: makeSelectIsSubscribed(props.uri, true)(state), isSubscribed: makeSelectIsSubscribed(props.uri, true)(state),
firstRunCompleted: selectFirstRunCompleted(state),
}); });
export default connect( export default connect(
@ -15,5 +21,6 @@ export default connect(
doChannelSubscribe, doChannelSubscribe,
doChannelUnsubscribe, doChannelUnsubscribe,
doOpenModal, doOpenModal,
doToast,
} }
)(SubscribeButton); )(SubscribeButton);

View file

@ -2,6 +2,7 @@
import * as MODALS from 'constants/modal_types'; import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import { parseURI } from 'lbry-redux';
import Button from 'component/button'; import Button from 'component/button';
type SubscribtionArgs = { type SubscribtionArgs = {
@ -10,30 +11,36 @@ type SubscribtionArgs = {
}; };
type Props = { type Props = {
channelName: ?string, uri: string,
uri: ?string,
isSubscribed: boolean, isSubscribed: boolean,
subscriptions: Array<string>, subscriptions: Array<string>,
doChannelSubscribe: ({ channelName: string, uri: string }) => void, doChannelSubscribe: ({ channelName: string, uri: string }) => void,
doChannelUnsubscribe: SubscribtionArgs => void, doChannelUnsubscribe: SubscribtionArgs => void,
doOpenModal: ({ id: string }) => void, doOpenModal: ({ id: string }) => void,
firstRunCompleted: boolean,
showSnackBarOnSubscribe: boolean,
doToast: ({ message: string }) => void,
}; };
export default (props: Props) => { export default (props: Props) => {
const { const {
channelName,
uri, uri,
doChannelSubscribe, doChannelSubscribe,
doChannelUnsubscribe, doChannelUnsubscribe,
doOpenModal, doOpenModal,
subscriptions, subscriptions,
isSubscribed, isSubscribed,
firstRunCompleted,
showSnackBarOnSubscribe,
doToast,
} = props; } = props;
const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe; const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe;
const subscriptionLabel = isSubscribed ? __('Unsubscribe') : __('Subscribe'); const subscriptionLabel = isSubscribed ? __('Unsubscribe') : __('Subscribe');
return channelName && uri ? ( const { claimName } = parseURI(uri);
return (
<Button <Button
iconColor="red" iconColor="red"
icon={isSubscribed ? undefined : ICONS.HEART} icon={isSubscribed ? undefined : ICONS.HEART}
@ -42,14 +49,19 @@ export default (props: Props) => {
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
if (!subscriptions.length) { if (!subscriptions.length && !firstRunCompleted) {
doOpenModal(MODALS.FIRST_SUBSCRIPTION); doOpenModal(MODALS.FIRST_SUBSCRIPTION);
} }
subscriptionHandler({ subscriptionHandler({
channelName, channelName: claimName,
uri, uri,
}); });
if (showSnackBarOnSubscribe) {
doToast({ message: `${__('Successfully subscribed to')} ${claimName}!` });
}
}} }}
/> />
) : null; );
}; };

View file

@ -0,0 +1,12 @@
import { connect } from 'react-redux';
import { selectSuggestedChannels } from 'redux/selectors/subscriptions';
import SuggestedSubscriptions from './view';
const select = state => ({
suggested: selectSuggestedChannels(state),
});
export default connect(
select,
null
)(SuggestedSubscriptions);

View file

@ -0,0 +1,23 @@
// @flow
import React, { PureComponent } from 'react';
import CategoryList from 'component/categoryList';
type Props = {
suggested: Array<{ label: string, uri: string }>,
};
class SuggestedSubscriptions extends PureComponent<Props> {
render() {
const { suggested } = this.props;
return suggested ? (
<div className="card__content subscriptions__suggested">
{suggested.map(({ uri, label }) => (
<CategoryList key={uri} category={label} categoryLink={uri} />
))}
</div>
) : null;
}
}
export default SuggestedSubscriptions;

View file

@ -2,7 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import CodeMirror from 'codemirror/lib/codemirror'; import CodeMirror from 'codemirror/lib/codemirror';
import { openSnippetMenu, stopContextMenu } from 'util/contextMenu'; import { openSnippetMenu, stopContextMenu } from 'util/context-menu';
// Addons // Addons
import 'codemirror/addon/selection/mark-selection'; import 'codemirror/addon/selection/mark-selection';

View file

@ -1,6 +1,6 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { stopContextMenu } from 'util/contextMenu'; import { stopContextMenu } from 'util/context-menu';
type Props = { type Props = {
source: string, source: string,

View file

@ -1,6 +1,6 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import { stopContextMenu } from 'util/contextMenu'; import { stopContextMenu } from 'util/context-menu';
type Props = { type Props = {
source: string, source: string,

View file

@ -3,7 +3,7 @@ import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { normalizeURI, SEARCH_TYPES, isURIValid } from 'lbry-redux'; import { normalizeURI, SEARCH_TYPES, isURIValid } from 'lbry-redux';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { parseQueryParams } from 'util/query_params'; import { parseQueryParams } from 'util/query-params';
import * as icons from 'constants/icons'; import * as icons from 'constants/icons';
import Autocomplete from './internal/autocomplete'; import Autocomplete from './internal/autocomplete';
@ -20,6 +20,7 @@ type Props = {
doBlur: () => void, doBlur: () => void,
resultCount: number, resultCount: number,
focused: boolean, focused: boolean,
doShowSnackBar: ({}) => void,
}; };
class WunderBar extends React.PureComponent<Props> { class WunderBar extends React.PureComponent<Props> {

View file

@ -189,6 +189,11 @@ export const FETCH_SUBSCRIPTIONS_START = 'FETCH_SUBSCRIPTIONS_START';
export const FETCH_SUBSCRIPTIONS_FAIL = 'FETCH_SUBSCRIPTIONS_FAIL'; export const FETCH_SUBSCRIPTIONS_FAIL = 'FETCH_SUBSCRIPTIONS_FAIL';
export const FETCH_SUBSCRIPTIONS_SUCCESS = 'FETCH_SUBSCRIPTIONS_SUCCESS'; export const FETCH_SUBSCRIPTIONS_SUCCESS = 'FETCH_SUBSCRIPTIONS_SUCCESS';
export const SET_VIEW_MODE = 'SET_VIEW_MODE'; export const SET_VIEW_MODE = 'SET_VIEW_MODE';
export const GET_SUGGESTED_SUBSCRIPTIONS_START = 'GET_SUGGESTED_SUBSCRIPTIONS_START';
export const GET_SUGGESTED_SUBSCRIPTIONS_SUCCESS = 'GET_SUGGESTED_SUBSCRIPTIONS_SUCCESS';
export const GET_SUGGESTED_SUBSCRIPTIONS_FAIL = 'GET_SUGGESTED_SUBSCRIPTIONS_FAIL';
export const SUBSCRIPTION_FIRST_RUN_COMPLETED = 'SUBSCRIPTION_FIRST_RUN_COMPLETED';
export const VIEW_SUGGESTED_SUBSCRIPTIONS = 'VIEW_SUGGESTED_SUBSCRIPTIONS';
// Publishing // Publishing
export const CLEAR_PUBLISH = 'CLEAR_PUBLISH'; export const CLEAR_PUBLISH = 'CLEAR_PUBLISH';

View file

@ -5,3 +5,8 @@ export const VIEW_LATEST_FIRST = 'view_latest_first';
export const DOWNLOADING = 'DOWNLOADING'; export const DOWNLOADING = 'DOWNLOADING';
export const DOWNLOADED = 'DOWNLOADED'; export const DOWNLOADED = 'DOWNLOADED';
export const NOTIFY_ONLY = 'NOTIFY_ONLY;'; export const NOTIFY_ONLY = 'NOTIFY_ONLY;';
// Suggested types
export const SUGGESTED_TOP_BID = 'top_bid';
export const SUGGESTED_TOP_SUBSCRIBED = 'top_subscribed';
export const SUGGESTED_FEATURED = 'featured';

View file

@ -1,11 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app'; import { doHideModal } from 'redux/actions/app';
import { doNavigate } from 'redux/actions/navigation';
import ModalFirstSubscription from './view'; import ModalFirstSubscription from './view';
const perform = dispatch => () => ({ const perform = dispatch => () => ({
closeModal: () => dispatch(doHideModal()), closeModal: () => dispatch(doHideModal()),
navigate: path => dispatch(doNavigate(path)),
}); });
export default connect( export default connect(

View file

@ -5,37 +5,24 @@ import Button from 'component/button';
type Props = { type Props = {
closeModal: () => void, closeModal: () => void,
navigate: string => void,
}; };
const ModalFirstSubscription = (props: Props) => { const ModalFirstSubscription = (props: Props) => {
const { closeModal, navigate } = props; const { closeModal } = props;
return ( return (
<Modal type="custom" isOpen contentLabel="Subscriptions 101" title={__('Subscriptions 101')}> <Modal type="custom" isOpen contentLabel="Subscriptions 101" title={__('Subscriptions 101')}>
<section className="card__content"> <section className="card__content">
<p>{__('You just subscribed to your first channel. Awesome!')}</p> <p>{__('You just subscribed to your first channel. Awesome!')}</p>
<p>{__('A few quick things to know:')}</p> <p>{__('A few quick things to know:')}</p>
<p className="card__content">
{__('1) You can use the')}{' '}
<Button
button="link"
label={__('Subscriptions Page')}
onClick={() => {
navigate('/subscriptions');
closeModal();
}}
/>{' '}
{__('to view content across all of your subscribed channels.')}
</p>
<p className="card__content"> <p className="card__content">
{__( {__(
'2) This app will automatically download new free content from channels you are subscribed to.' '1) This app will automatically download new free content from channels you are subscribed to.'
)} )}
</p> </p>
<p className="card__content"> <p className="card__content">
{__( {__(
'3) If we have your email address, we may send you notifications and rewards related to new content.' '2) If we have your email address, we may send you notifications and rewards related to new content.'
)} )}
</p> </p>
<div className="modal__buttons"> <div className="modal__buttons">

View file

@ -98,7 +98,7 @@ class ChannelPage extends React.PureComponent<Props> {
</h1> </h1>
</section> </section>
<div className="card__actions"> <div className="card__actions">
<SubscribeButton uri={`lbry://${permanentUrl}`} channelName={name} /> <SubscribeButton uri={`lbry://${permanentUrl}`} />
<Button <Button
button="alt" button="alt"
icon={icons.GLOBE} icon={icons.GLOBE}

View file

@ -19,7 +19,7 @@ import SubscribeButton from 'component/subscribeButton';
import Page from 'component/page'; import Page from 'component/page';
import FileDownloadLink from 'component/fileDownloadLink'; import FileDownloadLink from 'component/fileDownloadLink';
import classnames from 'classnames'; import classnames from 'classnames';
import getMediaType from 'util/getMediaType'; import getMediaType from 'util/get-media-type';
import RecommendedContent from 'component/recommendedContent'; import RecommendedContent from 'component/recommendedContent';
import { FormField, FormRow } from 'component/common/form'; import { FormField, FormRow } from 'component/common/form';
import ToolTip from 'component/common/tooltip'; import ToolTip from 'component/common/tooltip';
@ -208,7 +208,7 @@ class FilePage extends React.Component<Props> {
}} }}
/> />
) : ( ) : (
<SubscribeButton uri={channelUri} channelName={channelName} /> <SubscribeButton uri={channelUri} />
)} )}
{!claimIsMine && ( {!claimIsMine && (
<Button <Button

View file

@ -7,11 +7,15 @@ import {
selectIsFetchingSubscriptions, selectIsFetchingSubscriptions,
selectUnreadSubscriptions, selectUnreadSubscriptions,
selectViewMode, selectViewMode,
selectFirstRunCompleted,
selectshowSuggestedSubs,
} from 'redux/selectors/subscriptions'; } from 'redux/selectors/subscriptions';
import { import {
doUpdateUnreadSubscriptions,
doFetchMySubscriptions, doFetchMySubscriptions,
doSetViewMode, doSetViewMode,
doFetchRecommendedSubscriptions,
doCompleteFirstRun,
doShowSuggestedSubs,
} from 'redux/actions/subscriptions'; } from 'redux/actions/subscriptions';
import { doSetClientSetting } from 'redux/actions/settings'; import { doSetClientSetting } from 'redux/actions/settings';
import { makeSelectClientSetting } from 'redux/selectors/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings';
@ -26,14 +30,18 @@ const select = state => ({
allSubscriptions: selectSubscriptionClaims(state), allSubscriptions: selectSubscriptionClaims(state),
unreadSubscriptions: selectUnreadSubscriptions(state), unreadSubscriptions: selectUnreadSubscriptions(state),
viewMode: selectViewMode(state), viewMode: selectViewMode(state),
firstRunCompleted: selectFirstRunCompleted(state),
showSuggestedSubs: selectshowSuggestedSubs(state),
}); });
export default connect( export default connect(
select, select,
{ {
doUpdateUnreadSubscriptions,
doFetchMySubscriptions, doFetchMySubscriptions,
doSetClientSetting, doSetClientSetting,
doSetViewMode, doSetViewMode,
doFetchRecommendedSubscriptions,
doCompleteFirstRun,
doShowSuggestedSubs,
} }
)(SubscriptionsPage); )(SubscriptionsPage);

View file

@ -0,0 +1,63 @@
// @flow
import React, { Fragment } from 'react';
import Native from 'native';
import Button from 'component/button';
import SuggestedSubscriptions from 'component/subscribeSuggested';
type Props = {
showSuggested: boolean,
loadingSuggested: boolean,
numberOfSubscriptions: number,
onFinish: () => void,
doShowSuggestedSubs: () => void,
};
export default (props: Props) => {
const {
showSuggested,
loadingSuggested,
numberOfSubscriptions,
doShowSuggestedSubs,
onFinish,
} = props;
return (
<Fragment>
<div className="page__empty--horizontal">
<img
alt="Friendly gerbil"
className="subscriptions__gerbil"
src={Native.imagePath('gerbil-happy.png')}
/>
<div className="card__content">
<h2 className="card__title">
{numberOfSubscriptions > 0 ? __('Woohoo!') : __('No subscriptions... yet.')}
</h2>
<p className="card__subtitle">
{showSuggested
? __('I hear these channels are pretty good.')
: __("I'll tell you where the good channels are if you find me a wheel.")}
</p>
{!showSuggested && (
<div className="card__actions">
<Button button="primary" label={__('Explore')} onClick={doShowSuggestedSubs} />
</div>
)}
{showSuggested &&
numberOfSubscriptions > 0 && (
<div className="card__actions">
<Button
button="primary"
onClick={onFinish}
label={`${__('View your')} ${numberOfSubscriptions} ${
numberOfSubscriptions > 1 ? __('subcriptions') : __('subscription')
}`}
/>
</div>
)}
</div>
</div>
{showSuggested && !loadingSuggested && <SuggestedSubscriptions />}
</Fragment>
);
};

View file

@ -0,0 +1,134 @@
// @flow
import type { ViewMode } from 'types/subscription';
import type { Claim } from 'types/claim';
import { VIEW_ALL, VIEW_LATEST_FIRST } from 'constants/subscriptions';
import React, { Fragment } from 'react';
import Button from 'component/button';
import HiddenNsfwClaims from 'component/hiddenNsfwClaims';
import FileList from 'component/fileList';
import { FormField } from 'component/common/form';
import FileCard from 'component/fileCard';
import { parseURI } from 'lbry-redux';
import Native from 'native';
import SuggestedSubscriptions from 'component/subscribeSuggested';
type Props = {
viewMode: ViewMode,
doSetViewMode: ViewMode => void,
hasSubscriptions: boolean,
subscriptions: Array<{ uri: string, ...Claim }>,
autoDownload: boolean,
onChangeAutoDownload: (SyntheticInputEvent<*>) => void,
unreadSubscriptions: Array<{ channel: string, uris: Array<string> }>,
};
export default (props: Props) => {
const {
viewMode,
doSetViewMode,
hasSubscriptions,
subscriptions,
autoDownload,
onChangeAutoDownload,
unreadSubscriptions,
} = props;
return (
<Fragment>
<HiddenNsfwClaims
uris={subscriptions.reduce((arr, { name, claim_id: claimId }) => {
if (name && claimId) {
arr.push(`lbry://${name}#${claimId}`);
}
return arr;
}, [])}
/>
{hasSubscriptions && (
<div className="card--space-between">
<div className="card__actions card__actions--no-margin">
<Button
disabled={viewMode === VIEW_ALL}
button="link"
label="All Subscriptions"
onClick={() => doSetViewMode(VIEW_ALL)}
/>
<Button
button="link"
disabled={viewMode === VIEW_LATEST_FIRST}
label={__('Latest Only')}
onClick={() => doSetViewMode(VIEW_LATEST_FIRST)}
/>
</div>
<FormField
type="checkbox"
name="auto_download"
onChange={onChangeAutoDownload}
checked={autoDownload}
prefix={__('Auto download')}
/>
</div>
)}
{!hasSubscriptions && (
<Fragment>
<div className="page__empty--horizontal">
<img
alt="Sad gerbil"
className="subscriptions__gerbil"
src={Native.imagePath('gerbil-sad.png')}
/>
<div className="card__content">
<h2 className="card__title">{__('Oh no! What happened to your subscriptions?')}</h2>
<p className="card__subtitle">{__('These channels look pretty cool.')}</p>
</div>
</div>
<SuggestedSubscriptions />
</Fragment>
)}
{hasSubscriptions && (
<div className="card__content">
{viewMode === VIEW_ALL && (
<Fragment>
<div className="card__title">{__('Your subscriptions')}</div>
<FileList hideFilter sortByHeight fileInfos={subscriptions} />
</Fragment>
)}
{viewMode === VIEW_LATEST_FIRST && (
<Fragment>
{unreadSubscriptions.length ? (
unreadSubscriptions.map(({ channel, uris }) => {
const { claimName } = parseURI(channel);
return (
<section key={channel}>
<div className="card__title">
<Button
button="link"
navigate="/show"
navigateParams={{ uri: channel }}
label={claimName}
/>
</div>
<div className="card__list card__content">
{uris.map(uri => <FileCard isNew key={uri} uri={uri} />)}
</div>
</section>
);
})
) : (
<Fragment>
<div className="page__empty">
<h3 className="card__title">{__('All caught up!')}</h3>
<p className="card__subtitle">{__('You might like these channels.')}</p>
</div>
<SuggestedSubscriptions />
</Fragment>
)}
</Fragment>
)}
</div>
)}
</Fragment>
);
};

View file

@ -1,16 +1,11 @@
// @flow // @flow
import type { ViewMode } from 'types/subscription'; import type { ViewMode } from 'types/subscription';
import type { Claim } from 'types/claim'; import type { Claim } from 'types/claim';
import { VIEW_ALL, VIEW_LATEST_FIRST } from 'constants/subscriptions'; import * as SETTINGS from 'constants/settings';
import * as settings from 'constants/settings'; import React, { PureComponent } from 'react';
import * as React from 'react';
import Page from 'component/page'; import Page from 'component/page';
import Button from 'component/button'; import FirstRun from './internal/first-run';
import FileList from 'component/fileList'; import UserSubscriptions from './internal/user-subscriptions';
import HiddenNsfwClaims from 'component/hiddenNsfwClaims';
import { FormField } from 'component/common/form';
import FileCard from 'component/fileCard';
import { parseURI } from 'lbry-redux';
type Props = { type Props = {
subscribedChannels: Array<string>, // The channels a user is subscribed to subscribedChannels: Array<string>, // The channels a user is subscribed to
@ -25,9 +20,15 @@ type Props = {
doSetViewMode: ViewMode => void, doSetViewMode: ViewMode => void,
doFetchMySubscriptions: () => void, doFetchMySubscriptions: () => void,
doSetClientSetting: (string, boolean) => void, doSetClientSetting: (string, boolean) => void,
doFetchRecommendedSubscriptions: () => void,
loadingSuggested: boolean,
firstRunCompleted: boolean,
doCompleteFirstRun: () => void,
doShowSuggestedSubs: () => void,
showSuggestedSubs: boolean,
}; };
export default class extends React.PureComponent<Props> { export default class extends PureComponent<Props> {
constructor() { constructor() {
super(); super();
@ -35,56 +36,26 @@ export default class extends React.PureComponent<Props> {
} }
componentDidMount() { componentDidMount() {
const { doFetchMySubscriptions } = this.props; const {
doFetchMySubscriptions,
doFetchRecommendedSubscriptions,
allSubscriptions,
firstRunCompleted,
doShowSuggestedSubs,
} = this.props;
doFetchMySubscriptions(); doFetchMySubscriptions();
doFetchRecommendedSubscriptions();
// For channels that already have subscriptions, show the suggested subs right away
// This can probably be removed at a future date, it is just to make it so the "view your x subscriptions" button shows up right away
// Existing users will still go through the "first run"
if (!firstRunCompleted && allSubscriptions.length) {
doShowSuggestedSubs();
}
} }
onAutoDownloadChange(event: SyntheticInputEvent<*>) { onAutoDownloadChange(event: SyntheticInputEvent<*>) {
this.props.doSetClientSetting(settings.AUTO_DOWNLOAD, event.target.checked); this.props.doSetClientSetting(SETTINGS.AUTO_DOWNLOAD, event.target.checked);
}
renderSubscriptions() {
const { viewMode, unreadSubscriptions, allSubscriptions } = this.props;
if (viewMode === VIEW_ALL) {
return (
<React.Fragment>
<div className="card__title">{__('Your subscriptions')}</div>
<FileList hideFilter sortByHeight fileInfos={allSubscriptions} />
</React.Fragment>
);
}
return (
<React.Fragment>
{unreadSubscriptions.length ? (
unreadSubscriptions.map(({ channel, uris }) => {
const { claimName } = parseURI(channel);
return (
<section key={channel}>
<div className="card__title">
<Button
button="link"
navigate="/show"
navigateParams={{ uri: channel }}
label={claimName}
/>
</div>
<div className="card__list card__content">
{uris.map(uri => <FileCard isNew key={uri} uri={uri} />)}
</div>
</section>
);
})
) : (
<div className="page__empty">
<h3 className="card__title">{__('You are all caught up!')}</h3>
<div className="card__actions">
<Button button="primary" navigate="/discover" label={__('Explore new content')} />
</div>
</div>
)}
</React.Fragment>
);
} }
render() { render() {
@ -95,58 +66,39 @@ export default class extends React.PureComponent<Props> {
autoDownload, autoDownload,
viewMode, viewMode,
doSetViewMode, doSetViewMode,
loadingSuggested,
firstRunCompleted,
doCompleteFirstRun,
doShowSuggestedSubs,
showSuggestedSubs,
unreadSubscriptions,
} = this.props; } = this.props;
const numberOfSubscriptions = subscribedChannels && subscribedChannels.length;
return ( return (
// Only pass in the loading prop if there are no subscriptions // Only pass in the loading prop if there are no subscriptions
// If there are any, let the page update in the background // If there are any, let the page update in the background
// The loading prop removes children and shows a loading spinner // The loading prop removes children and shows a loading spinner
<Page notContained loading={loading && !subscribedChannels}> <Page notContained loading={loading && !subscribedChannels}>
<HiddenNsfwClaims {firstRunCompleted ? (
uris={allSubscriptions.reduce((arr, { name, claim_id: claimId }) => { <UserSubscriptions
if (name && claimId) { viewMode={viewMode}
arr.push(`lbry://${name}#${claimId}`); doSetViewMode={doSetViewMode}
} hasSubscriptions={numberOfSubscriptions > 0}
return arr; subscriptions={allSubscriptions}
}, [])} autoDownload={autoDownload}
/> onChangeAutoDownload={this.onAutoDownloadChange}
{!!subscribedChannels.length && ( unreadSubscriptions={unreadSubscriptions}
<div className="card--space-between"> loadingSuggested={loadingSuggested}
<div className="card__actions card__actions--no-margin"> />
<Button ) : (
disabled={viewMode === VIEW_ALL} <FirstRun
button="link" showSuggested={showSuggestedSubs}
label="All Subscriptions" doShowSuggestedSubs={doShowSuggestedSubs}
onClick={() => doSetViewMode(VIEW_ALL)} loadingSuggested={loadingSuggested}
/> numberOfSubscriptions={numberOfSubscriptions}
<Button onFinish={doCompleteFirstRun}
button="link" />
disabled={viewMode === VIEW_LATEST_FIRST}
label={__('Latest Only')}
onClick={() => doSetViewMode(VIEW_LATEST_FIRST)}
/>
</div>
<FormField
type="checkbox"
name="auto_download"
onChange={this.onAutoDownloadChange}
checked={autoDownload}
prefix={__('Auto download')}
/>
</div>
)}
{!subscribedChannels.length && (
<div className="page__empty">
<h3 className="card__title">
{__("It looks like you aren't subscribed to any channels yet.")}
</h3>
<div className="card__actions">
<Button button="primary" navigate="/discover" label={__('Explore new content')} />
</div>
</div>
)}
{!!subscribedChannels.length && (
<div className="card__content">{this.renderSubscriptions()}</div>
)} )}
</Page> </Page>
); );

View file

@ -24,11 +24,11 @@ import {
makeSelectChannelForClaimUri, makeSelectChannelForClaimUri,
parseURI, parseURI,
creditsToString, creditsToString,
doError doError,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectClientSetting, selectosNotificationsEnabled } from 'redux/selectors/settings'; import { makeSelectClientSetting, selectosNotificationsEnabled } from 'redux/selectors/settings';
import setBadge from 'util/setBadge'; import setBadge from 'util/set-badge';
import setProgressBar from 'util/setProgressBar'; import setProgressBar from 'util/set-progress-bar';
import analytics from 'analytics'; import analytics from 'analytics';
const DOWNLOAD_POLL_INTERVAL = 250; const DOWNLOAD_POLL_INTERVAL = 250;

View file

@ -10,7 +10,7 @@ import {
} from 'lbry-redux'; } from 'lbry-redux';
import { doHideModal } from 'redux/actions/app'; import { doHideModal } from 'redux/actions/app';
import { doHistoryBack } from 'redux/actions/navigation'; import { doHistoryBack } from 'redux/actions/navigation';
import setProgressBar from 'util/setProgressBar'; import setProgressBar from 'util/set-progress-bar';
export function doOpenFileInFolder(path) { export function doOpenFileInFolder(path) {
return () => { return () => {

View file

@ -1,5 +1,5 @@
import { ACTIONS, selectHistoryIndex, selectHistoryStack } from 'lbry-redux'; import { ACTIONS, selectHistoryIndex, selectHistoryStack } from 'lbry-redux';
import { toQueryString } from 'util/query_params'; import { toQueryString } from 'util/query-params';
import analytics from 'analytics'; import analytics from 'analytics';
export function doNavigate(path, params = {}, options = {}) { export function doNavigate(path, params = {}, options = {}) {

View file

@ -394,3 +394,33 @@ export const doCheckSubscriptionsInit = () => (dispatch: ReduxDispatch) => {
data: { checkSubscriptionsTimer }, data: { checkSubscriptionsTimer },
}); });
}; };
export const doFetchRecommendedSubscriptions = () => (dispatch: ReduxDispatch) => {
dispatch({
type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_START,
});
return Lbryio.call('subscription', 'suggest')
.then(suggested =>
dispatch({
type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_SUCCESS,
data: suggested,
})
)
.catch(error =>
dispatch({
type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_FAIL,
error,
})
);
};
export const doCompleteFirstRun = () => (dispatch: ReduxDispatch) =>
dispatch({
type: ACTIONS.SUBSCRIPTION_FIRST_RUN_COMPLETED,
});
export const doShowSuggestedSubs = () => (dispatch: ReduxDispatch) =>
dispatch({
type: ACTIONS.VIEW_SUGGESTED_SUBSCRIPTIONS,
});

View file

@ -12,13 +12,18 @@ import type {
DoRemoveSubscriptionUnreads, DoRemoveSubscriptionUnreads,
FetchedSubscriptionsSucess, FetchedSubscriptionsSucess,
SetViewMode, SetViewMode,
GetSuggestedSubscriptionsSuccess,
} from 'types/subscription'; } from 'types/subscription';
const defaultState: SubscriptionState = { const defaultState: SubscriptionState = {
subscriptions: [], subscriptions: [],
unread: {}, unread: {},
suggested: {},
loading: false, loading: false,
viewMode: VIEW_ALL, viewMode: VIEW_ALL,
loadingSuggested: false,
firstRunCompleted: false,
showSuggestedSubs: false,
}; };
export default handleActions( export default handleActions(
@ -127,6 +132,30 @@ export default handleActions(
...state, ...state,
viewMode: action.data, viewMode: action.data,
}), }),
[ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_START]: (state: SubscriptionState): SubscriptionState => ({
...state,
loadingSuggested: true,
}),
[ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_SUCCESS]: (
state: SubscriptionState,
action: GetSuggestedSubscriptionsSuccess
): SubscriptionState => ({
...state,
suggested: action.data,
loadingSuggested: false,
}),
[ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_FAIL]: (state: SubscriptionState): SubscriptionState => ({
...state,
loadingSuggested: false,
}),
[ACTIONS.SUBSCRIPTION_FIRST_RUN_COMPLETED]: (state: SubscriptionState): SubscriptionState => ({
...state,
firstRunCompleted: true,
}),
[ACTIONS.VIEW_SUGGESTED_SUBSCRIPTIONS]: (state: SubscriptionState): SubscriptionState => ({
...state,
showSuggestedSubs: true,
}),
}, },
defaultState defaultState
); );

View file

@ -1,3 +1,4 @@
import { SUGGESTED_FEATURED, SUGGESTED_TOP_SUBSCRIBED } from 'constants/subscriptions';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { import {
selectAllClaimsByChannel, selectAllClaimsByChannel,
@ -7,6 +8,7 @@ import {
selectClaimsByUri, selectClaimsByUri,
parseURI, parseURI,
} from 'lbry-redux'; } from 'lbry-redux';
import { swapKeyAndValue } from 'util/swap-json';
// Returns the entire subscriptions state // Returns the entire subscriptions state
const selectState = state => state.subscriptions || {}; const selectState = state => state.subscriptions || {};
@ -20,6 +22,72 @@ export const selectIsFetchingSubscriptions = createSelector(selectState, state =
// The current view mode on the subscriptions page // The current view mode on the subscriptions page
export const selectViewMode = createSelector(selectState, state => state.viewMode); export const selectViewMode = createSelector(selectState, state => state.viewMode);
// Suggested subscriptions from internal apis
export const selectSuggested = createSelector(selectState, state => state.suggested);
export const selectLoadingSuggested = createSelector(selectState, state => state.loadingSuggested);
export const selectSuggestedChannels = createSelector(
selectSubscriptions,
selectSuggested,
(userSubscriptions, suggested) => {
if (!suggested) {
return null;
}
// Swap the key/value because we will use the uri for everything, this just makes it easier
// suggested is returned from the api with the form:
// {
// featured: { "Channel label": uri, ... },
// top_subscribed: { "@channel": uri, ... }
// top_bid: { "@channel": uri, ... }
// }
// To properly compare the suggested subscriptions from our current subscribed channels
// We only care about the uri, not the label
// We also only care about top_subscribed and featured
// top_bid could just be porn or a channel with no content
const topSubscribedSuggestions = swapKeyAndValue(suggested[SUGGESTED_TOP_SUBSCRIBED]);
const featuredSuggestions = swapKeyAndValue(suggested[SUGGESTED_FEATURED]);
// Make sure there are no duplicates
// If a uri isn't already in the suggested object, add it
const suggestedChannels = { ...topSubscribedSuggestions };
Object.keys(featuredSuggestions).forEach(uri => {
if (!suggestedChannels[uri]) {
const channelLabel = featuredSuggestions[uri];
suggestedChannels[uri] = channelLabel;
}
});
userSubscriptions.forEach(({ uri }) => {
// Note to passer bys:
// Maybe we should just remove the `lbry://` prefix from subscription uris
// Most places don't store them like that
const subscribedUri = uri.slice('lbry://'.length);
if (suggestedChannels[subscribedUri]) {
delete suggestedChannels[subscribedUri];
}
});
return Object.keys(suggestedChannels)
.map(uri => ({
uri,
label: suggestedChannels[uri],
}))
.slice(0, 5);
}
);
export const selectFirstRunCompleted = createSelector(
selectState,
state => state.firstRunCompleted
);
export const selectshowSuggestedSubs = createSelector(
selectState,
state => state.showSuggestedSubs
);
// Fetching any claims that are a part of a users subscriptions // Fetching any claims that are a part of a users subscriptions
export const selectSubscriptionsBeingFetched = createSelector( export const selectSubscriptionsBeingFetched = createSelector(
selectSubscriptions, selectSubscriptions,

View file

@ -192,9 +192,20 @@ p:not(:first-of-type) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 200px; margin-top: 200px;
margin-bottom: 100px;
text-align: center; text-align: center;
} }
// Empty pages that display columns of content
.page__empty--horizontal {
max-width: 60vw;
margin: auto;
display: flex;
flex-direction: row;
text-align: left;
justify-content: center;
}
.columns { .columns {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -6,4 +6,5 @@
'component/markdown-editor', 'component/scrollbar', 'component/spinner', 'component/nav', 'component/markdown-editor', 'component/scrollbar', 'component/spinner', 'component/nav',
'component/file-list', 'component/file-render', 'component/search', 'component/toggle', 'component/file-list', 'component/file-render', 'component/search', 'component/toggle',
'component/dat-gui', 'component/item-list', 'component/time', 'component/icon', 'component/dat-gui', 'component/item-list', 'component/time', 'component/icon',
'component/placeholder', 'component/badge', 'component/expandable', 'themes/dark'; 'component/placeholder', 'component/badge', 'component/expandable', 'component/subscriptions',
'themes/dark';

View file

@ -0,0 +1,23 @@
// The gerbil is tied to subscriptions currently, but this style should move to it's own file once
// the gerbil is added in more places with different layouts
.subscriptions__gerbil {
height: 250px;
width: 210px;
}
.subscriptions__suggested {
animation: expand 0.2s;
width: 100%;
margin-top: $spacing-vertical;
}
@-webkit-keyframes expand {
0% {
margin-top: 200px;
opacity: 0;
}
100% {
margin-top: $spacing-vertical;
opacity: 1;
}
}

View file

@ -101,7 +101,6 @@ const compressor = createCompressor();
// We were caching so much data the app was locking up // We were caching so much data the app was locking up
// We can't add this back until we can perform this in a non-blocking way // We can't add this back until we can perform this in a non-blocking way
// const saveClaimsFilter = createFilter('claims', ['byId', 'claimsByUri']); // const saveClaimsFilter = createFilter('claims', ['byId', 'claimsByUri']);
const subscriptionsFilter = createFilter('subscriptions', ['subscriptions', 'unread', 'viewMode']);
const contentFilter = createFilter('content', ['positions', 'history']); const contentFilter = createFilter('content', ['positions', 'history']);
const fileInfoFilter = createFilter('fileInfo', [ const fileInfoFilter = createFilter('fileInfo', [
'fileListPublishedSort', 'fileListPublishedSort',
@ -116,14 +115,7 @@ const persistOptions = {
whitelist: ['subscriptions', 'publish', 'wallet', 'content', 'fileInfo', 'app'], whitelist: ['subscriptions', 'publish', 'wallet', 'content', 'fileInfo', 'app'],
// Order is important. Needs to be compressed last or other transforms can't // Order is important. Needs to be compressed last or other transforms can't
// read the data // read the data
transforms: [ transforms: [walletFilter, contentFilter, fileInfoFilter, appFilter, compressor],
subscriptionsFilter,
walletFilter,
contentFilter,
fileInfoFilter,
appFilter,
compressor,
],
debounce: 10000, debounce: 10000,
storage: localForage, storage: localForage,
}; };

View file

@ -7,6 +7,9 @@ import {
NOTIFY_ONLY, NOTIFY_ONLY,
VIEW_ALL, VIEW_ALL,
VIEW_LATEST_FIRST, VIEW_LATEST_FIRST,
SUGGESTED_TOP_BID,
SUGGESTED_TOP_SUBSCRIBED,
SUGGESTED_FEATURED,
} from 'constants/subscriptions'; } from 'constants/subscriptions';
export type Subscription = { export type Subscription = {
@ -31,11 +34,21 @@ export type UnreadSubscriptions = {
export type ViewMode = VIEW_LATEST_FIRST | VIEW_ALL; export type ViewMode = VIEW_LATEST_FIRST | VIEW_ALL;
export type SuggestedType = SUGGESTED_TOP_BID | SUGGESTED_TOP_SUBSCRIBED | SUGGESTED_FEATURED;
export type SuggestedSubscriptions = {
[SuggestedType]: string,
};
export type SubscriptionState = { export type SubscriptionState = {
subscriptions: Array<Subscription>, subscriptions: Array<Subscription>,
unread: UnreadSubscriptions, unread: UnreadSubscriptions,
loading: boolean, loading: boolean,
viewMode: ViewMode, viewMode: ViewMode,
suggested: SuggestedSubscriptions,
loadingSuggested: boolean,
firstRunCompleted: boolean,
showSuggestedSubs: boolean,
}; };
// //
@ -94,6 +107,11 @@ export type SetViewMode = {
data: ViewMode, data: ViewMode,
}; };
export type GetSuggestedSubscriptionsSuccess = {
type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_START,
data: SuggestedSubscriptions,
};
export type Action = export type Action =
| DoChannelSubscribe | DoChannelSubscribe
| DoChannelUnsubscribe | DoChannelUnsubscribe

View file

@ -0,0 +1,10 @@
export function swapKeyAndValue(dict) {
const ret = {};
// eslint-disable-next-line no-restricted-syntax
for (const key in dict) {
if (dict.hasOwnProperty(key)) {
ret[dict[key]] = key;
}
}
return ret;
}

BIN
static/img/gerbil-happy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
static/img/gerbil-sad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB