diff --git a/.eslintrc.json b/.eslintrc.json index a8f285504..22a808553 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -44,6 +44,7 @@ "jsx-a11y/interactive-supports-focus": 0, "jsx-a11y/click-events-have-key-events": 0, "consistent-return": 0, + "no-prototype-builtins": 0, "flowtype/space-after-type-colon": [ 2, "always", { "allowLineBreak": true } ] } } diff --git a/src/renderer/component/app/view.jsx b/src/renderer/component/app/view.jsx index 6aebf7706..eadfc77ab 100644 --- a/src/renderer/component/app/view.jsx +++ b/src/renderer/component/app/view.jsx @@ -6,7 +6,7 @@ import ReactModal from 'react-modal'; import throttle from 'util/throttle'; import SideBar from 'component/sideBar'; 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; diff --git a/src/renderer/component/categoryList/view.jsx b/src/renderer/component/categoryList/view.jsx index cf39c06af..240460caf 100644 --- a/src/renderer/component/categoryList/view.jsx +++ b/src/renderer/component/categoryList/view.jsx @@ -1,18 +1,19 @@ // @flow -import * as React from 'react'; +import type { Claim } from 'types/claim'; +import React, { PureComponent } from 'react'; import { normalizeURI } from 'lbry-redux'; import ToolTip from 'component/common/tooltip'; import FileCard from 'component/fileCard'; import Button from 'component/button'; import * as icons from 'constants/icons'; -import type { Claim } from 'types/claim'; +import SubscribeButton from 'component/subscribeButton'; type Props = { category: string, - names: Array<string>, + names: ?Array<string>, categoryLink: ?string, fetching: boolean, - channelClaims: Array<Claim>, + channelClaims: ?Array<Claim>, fetchChannel: string => void, obscureNsfw: boolean, }; @@ -22,9 +23,8 @@ type State = { canScrollPrevious: boolean, }; -class CategoryList extends React.PureComponent<Props, State> { +class CategoryList extends PureComponent<Props, State> { static defaultProps = { - names: [], categoryLink: '', }; @@ -209,7 +209,6 @@ class CategoryList extends React.PureComponent<Props, State> { render() { const { category, categoryLink, names, channelClaims, obscureNsfw } = this.props; const { canScrollNext, canScrollPrevious } = this.state; - const isCommunityTopBids = category.match(/^community/i); 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__title"> {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 )} @@ -263,19 +265,33 @@ class CategoryList extends React.PureComponent<Props, State> { }} > {names && + names.length && names.map(name => ( <FileCard showSubscribedLogo key={name} uri={normalizeURI(name)} /> ))} {channelClaims && channelClaims.length && - channelClaims.map(claim => ( - <FileCard - showSubcribedLogo - key={claim.claim_id} - uri={`lbry://${claim.name}#${claim.claim_id}`} - /> - ))} + channelClaims + // Only show the first 10 claims, regardless of the amount we have on a channel page + .slice(0, 10) + .map(claim => ( + <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> diff --git a/src/renderer/component/channelTile/view.jsx b/src/renderer/component/channelTile/view.jsx index 8edbc1e03..6bde45cd8 100644 --- a/src/renderer/component/channelTile/view.jsx +++ b/src/renderer/component/channelTile/view.jsx @@ -76,7 +76,7 @@ class ChannelTile extends React.PureComponent<Props> { )} {subscriptionUri && ( <div className="card__actions"> - <SubscribeButton uri={subscriptionUri} channelName={channelName} /> + <SubscribeButton uri={subscriptionUri} /> </div> )} </div> diff --git a/src/renderer/component/common/credit-amount.jsx b/src/renderer/component/common/credit-amount.jsx index 0f8d89338..b15ba435a 100644 --- a/src/renderer/component/common/credit-amount.jsx +++ b/src/renderer/component/common/credit-amount.jsx @@ -1,7 +1,7 @@ // @flow import React from 'react'; import classnames from 'classnames'; -import { formatCredits, formatFullPrice } from 'util/formatCredits'; +import { formatCredits, formatFullPrice } from 'util/format-credits'; type Props = { amount: number, diff --git a/src/renderer/component/common/file-exporter.jsx b/src/renderer/component/common/file-exporter.jsx index fe32fa1d4..18364dd79 100644 --- a/src/renderer/component/common/file-exporter.jsx +++ b/src/renderer/component/common/file-exporter.jsx @@ -3,7 +3,7 @@ import fs from 'fs'; import path from 'path'; import React from 'react'; import Button from 'component/button'; -import parseData from 'util/parseData'; +import parseData from 'util/parse-data'; import * as icons from 'constants/icons'; import { remote } from 'electron'; @@ -33,7 +33,10 @@ class FileExporter extends React.PureComponent<Props> { fs.writeFile(filename, data, err => { if (err) throw err; // 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.getCurrentWindow(), - options, - filename => { - // User hit cancel so do nothing: - if (!filename) return; - // Get extension and remove initial dot - const format = path.extname(filename).replace(/\./g, ''); - // Parse data to string with the chosen format - const parsed = parseData(data, format, filters); - // Write file - parsed && this.handleFileCreation(filename, parsed); + remote.dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { + // User hit cancel so do nothing: + if (!filename) return; + // Get extension and remove initial dot + const format = path.extname(filename).replace(/\./g, ''); + // Parse data to string with the chosen format + const parsed = parseData(data, format, filters); + // Write file + if (parsed) { + this.handleFileCreation(filename, parsed); } - ); + }); } render() { - const { title, label } = this.props; + const { label } = this.props; return ( <Button button="primary" diff --git a/src/renderer/component/common/form-components/form-field.jsx b/src/renderer/component/common/form-components/form-field.jsx index 21d52c2b0..39dee43b0 100644 --- a/src/renderer/component/common/form-components/form-field.jsx +++ b/src/renderer/component/common/form-components/form-field.jsx @@ -6,7 +6,7 @@ import MarkdownPreview from 'component/common/markdown-preview'; import SimpleMDE from 'react-simplemde-editor'; import 'simplemde/dist/simplemde.min.css'; // eslint-disable-line import/no-extraneous-dependencies import Toggle from 'react-toggle'; -import { openEditorMenu, stopContextMenu } from 'util/contextMenu'; +import { openEditorMenu, stopContextMenu } from 'util/context-menu'; type Props = { name: string, diff --git a/src/renderer/component/fileCard/view.jsx b/src/renderer/component/fileCard/view.jsx index 4beb91860..d4f2bc814 100644 --- a/src/renderer/component/fileCard/view.jsx +++ b/src/renderer/component/fileCard/view.jsx @@ -9,7 +9,7 @@ import UriIndicator from 'component/uriIndicator'; import * as icons from 'constants/icons'; import classnames from 'classnames'; import FilePrice from 'component/filePrice'; -import { openCopyLinkMenu } from 'util/contextMenu'; +import { openCopyLinkMenu } from 'util/context-menu'; import DateTime from 'component/dateTime'; type Props = { diff --git a/src/renderer/component/header/index.js b/src/renderer/component/header/index.js index 50ccac3ff..51407b726 100644 --- a/src/renderer/component/header/index.js +++ b/src/renderer/component/header/index.js @@ -1,6 +1,6 @@ import { connect } from 'react-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 { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app'; import { doDownloadUpgradeRequested } from 'redux/actions/app'; diff --git a/src/renderer/component/page/index.js b/src/renderer/component/page/index.js index 1cbffa8ce..b64e3b4b9 100644 --- a/src/renderer/component/page/index.js +++ b/src/renderer/component/page/index.js @@ -8,7 +8,7 @@ import { import { doNavigate, doHistoryBack, doHistoryForward } from 'redux/actions/navigation'; import { doDownloadUpgrade } from 'redux/actions/app'; import { selectIsUpgradeAvailable, selectNavLinks } from 'redux/selectors/app'; -import { formatCredits } from 'util/formatCredits'; +import { formatCredits } from 'util/format-credits'; import Page from './view'; const select = state => ({ diff --git a/src/renderer/component/subscribeButton/index.js b/src/renderer/component/subscribeButton/index.js index bbc6c6a87..46590d0c1 100644 --- a/src/renderer/component/subscribeButton/index.js +++ b/src/renderer/component/subscribeButton/index.js @@ -1,12 +1,18 @@ import { connect } from 'react-redux'; import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions'; 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'; const select = (state, props) => ({ subscriptions: selectSubscriptions(state), isSubscribed: makeSelectIsSubscribed(props.uri, true)(state), + firstRunCompleted: selectFirstRunCompleted(state), }); export default connect( @@ -15,5 +21,6 @@ export default connect( doChannelSubscribe, doChannelUnsubscribe, doOpenModal, + doToast, } )(SubscribeButton); diff --git a/src/renderer/component/subscribeButton/view.jsx b/src/renderer/component/subscribeButton/view.jsx index 0099ecb36..a95aa49b8 100644 --- a/src/renderer/component/subscribeButton/view.jsx +++ b/src/renderer/component/subscribeButton/view.jsx @@ -2,6 +2,7 @@ import * as MODALS from 'constants/modal_types'; import * as ICONS from 'constants/icons'; import React from 'react'; +import { parseURI } from 'lbry-redux'; import Button from 'component/button'; type SubscribtionArgs = { @@ -10,30 +11,36 @@ type SubscribtionArgs = { }; type Props = { - channelName: ?string, - uri: ?string, + uri: string, isSubscribed: boolean, subscriptions: Array<string>, doChannelSubscribe: ({ channelName: string, uri: string }) => void, doChannelUnsubscribe: SubscribtionArgs => void, doOpenModal: ({ id: string }) => void, + firstRunCompleted: boolean, + showSnackBarOnSubscribe: boolean, + doToast: ({ message: string }) => void, }; export default (props: Props) => { const { - channelName, uri, doChannelSubscribe, doChannelUnsubscribe, doOpenModal, subscriptions, isSubscribed, + firstRunCompleted, + showSnackBarOnSubscribe, + doToast, } = props; const subscriptionHandler = isSubscribed ? doChannelUnsubscribe : doChannelSubscribe; const subscriptionLabel = isSubscribed ? __('Unsubscribe') : __('Subscribe'); - return channelName && uri ? ( + const { claimName } = parseURI(uri); + + return ( <Button iconColor="red" icon={isSubscribed ? undefined : ICONS.HEART} @@ -42,14 +49,19 @@ export default (props: Props) => { onClick={e => { e.stopPropagation(); - if (!subscriptions.length) { + if (!subscriptions.length && !firstRunCompleted) { doOpenModal(MODALS.FIRST_SUBSCRIPTION); } + subscriptionHandler({ - channelName, + channelName: claimName, uri, }); + + if (showSnackBarOnSubscribe) { + doToast({ message: `${__('Successfully subscribed to')} ${claimName}!` }); + } }} /> - ) : null; + ); }; diff --git a/src/renderer/component/subscribeSuggested/index.js b/src/renderer/component/subscribeSuggested/index.js new file mode 100644 index 000000000..782664e65 --- /dev/null +++ b/src/renderer/component/subscribeSuggested/index.js @@ -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); diff --git a/src/renderer/component/subscribeSuggested/view.jsx b/src/renderer/component/subscribeSuggested/view.jsx new file mode 100644 index 000000000..b9368e95e --- /dev/null +++ b/src/renderer/component/subscribeSuggested/view.jsx @@ -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; diff --git a/src/renderer/component/viewers/codeViewer.jsx b/src/renderer/component/viewers/codeViewer.jsx index d10f3135c..1d0cd0620 100644 --- a/src/renderer/component/viewers/codeViewer.jsx +++ b/src/renderer/component/viewers/codeViewer.jsx @@ -2,7 +2,7 @@ import * as React from 'react'; import CodeMirror from 'codemirror/lib/codemirror'; -import { openSnippetMenu, stopContextMenu } from 'util/contextMenu'; +import { openSnippetMenu, stopContextMenu } from 'util/context-menu'; // Addons import 'codemirror/addon/selection/mark-selection'; diff --git a/src/renderer/component/viewers/htmlViewer.jsx b/src/renderer/component/viewers/htmlViewer.jsx index 029d7e15d..f3048c553 100644 --- a/src/renderer/component/viewers/htmlViewer.jsx +++ b/src/renderer/component/viewers/htmlViewer.jsx @@ -1,6 +1,6 @@ // @flow import React from 'react'; -import { stopContextMenu } from 'util/contextMenu'; +import { stopContextMenu } from 'util/context-menu'; type Props = { source: string, diff --git a/src/renderer/component/viewers/pdfViewer.jsx b/src/renderer/component/viewers/pdfViewer.jsx index e297adfd1..27c60f6b8 100644 --- a/src/renderer/component/viewers/pdfViewer.jsx +++ b/src/renderer/component/viewers/pdfViewer.jsx @@ -1,6 +1,6 @@ // @flow import * as React from 'react'; -import { stopContextMenu } from 'util/contextMenu'; +import { stopContextMenu } from 'util/context-menu'; type Props = { source: string, diff --git a/src/renderer/component/wunderbar/view.jsx b/src/renderer/component/wunderbar/view.jsx index ee9ab7f98..b1f817ef8 100644 --- a/src/renderer/component/wunderbar/view.jsx +++ b/src/renderer/component/wunderbar/view.jsx @@ -3,7 +3,7 @@ import React from 'react'; import classnames from 'classnames'; import { normalizeURI, SEARCH_TYPES, isURIValid } from 'lbry-redux'; 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 Autocomplete from './internal/autocomplete'; @@ -20,6 +20,7 @@ type Props = { doBlur: () => void, resultCount: number, focused: boolean, + doShowSnackBar: ({}) => void, }; class WunderBar extends React.PureComponent<Props> { diff --git a/src/renderer/constants/action_types.js b/src/renderer/constants/action_types.js index 8f719ecb6..af47919d4 100644 --- a/src/renderer/constants/action_types.js +++ b/src/renderer/constants/action_types.js @@ -189,6 +189,11 @@ export const FETCH_SUBSCRIPTIONS_START = 'FETCH_SUBSCRIPTIONS_START'; export const FETCH_SUBSCRIPTIONS_FAIL = 'FETCH_SUBSCRIPTIONS_FAIL'; export const FETCH_SUBSCRIPTIONS_SUCCESS = 'FETCH_SUBSCRIPTIONS_SUCCESS'; 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 export const CLEAR_PUBLISH = 'CLEAR_PUBLISH'; diff --git a/src/renderer/constants/subscriptions.js b/src/renderer/constants/subscriptions.js index fe419c8d5..c6365805b 100644 --- a/src/renderer/constants/subscriptions.js +++ b/src/renderer/constants/subscriptions.js @@ -5,3 +5,8 @@ export const VIEW_LATEST_FIRST = 'view_latest_first'; export const DOWNLOADING = 'DOWNLOADING'; export const DOWNLOADED = 'DOWNLOADED'; 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'; diff --git a/src/renderer/modal/modalFirstSubscription/index.js b/src/renderer/modal/modalFirstSubscription/index.js index 60e78b00b..1a30ca3a0 100644 --- a/src/renderer/modal/modalFirstSubscription/index.js +++ b/src/renderer/modal/modalFirstSubscription/index.js @@ -1,11 +1,9 @@ import { connect } from 'react-redux'; import { doHideModal } from 'redux/actions/app'; -import { doNavigate } from 'redux/actions/navigation'; import ModalFirstSubscription from './view'; const perform = dispatch => () => ({ closeModal: () => dispatch(doHideModal()), - navigate: path => dispatch(doNavigate(path)), }); export default connect( diff --git a/src/renderer/modal/modalFirstSubscription/view.jsx b/src/renderer/modal/modalFirstSubscription/view.jsx index 7ca25a4cb..df59e216e 100644 --- a/src/renderer/modal/modalFirstSubscription/view.jsx +++ b/src/renderer/modal/modalFirstSubscription/view.jsx @@ -5,37 +5,24 @@ import Button from 'component/button'; type Props = { closeModal: () => void, - navigate: string => void, }; const ModalFirstSubscription = (props: Props) => { - const { closeModal, navigate } = props; + const { closeModal } = props; return ( <Modal type="custom" isOpen contentLabel="Subscriptions 101" title={__('Subscriptions 101')}> <section className="card__content"> <p>{__('You just subscribed to your first channel. Awesome!')}</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"> {__( - '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 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> <div className="modal__buttons"> diff --git a/src/renderer/page/channel/view.jsx b/src/renderer/page/channel/view.jsx index 023ad2c1e..42ac0cb31 100644 --- a/src/renderer/page/channel/view.jsx +++ b/src/renderer/page/channel/view.jsx @@ -98,7 +98,7 @@ class ChannelPage extends React.PureComponent<Props> { </h1> </section> <div className="card__actions"> - <SubscribeButton uri={`lbry://${permanentUrl}`} channelName={name} /> + <SubscribeButton uri={`lbry://${permanentUrl}`} /> <Button button="alt" icon={icons.GLOBE} diff --git a/src/renderer/page/file/view.jsx b/src/renderer/page/file/view.jsx index 35909228f..04250c4ce 100644 --- a/src/renderer/page/file/view.jsx +++ b/src/renderer/page/file/view.jsx @@ -19,7 +19,7 @@ import SubscribeButton from 'component/subscribeButton'; import Page from 'component/page'; import FileDownloadLink from 'component/fileDownloadLink'; import classnames from 'classnames'; -import getMediaType from 'util/getMediaType'; +import getMediaType from 'util/get-media-type'; import RecommendedContent from 'component/recommendedContent'; import { FormField, FormRow } from 'component/common/form'; 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 && ( <Button diff --git a/src/renderer/page/subscriptions/index.js b/src/renderer/page/subscriptions/index.js index a62734e5d..aea21d76b 100644 --- a/src/renderer/page/subscriptions/index.js +++ b/src/renderer/page/subscriptions/index.js @@ -7,11 +7,15 @@ import { selectIsFetchingSubscriptions, selectUnreadSubscriptions, selectViewMode, + selectFirstRunCompleted, + selectshowSuggestedSubs, } from 'redux/selectors/subscriptions'; import { - doUpdateUnreadSubscriptions, doFetchMySubscriptions, doSetViewMode, + doFetchRecommendedSubscriptions, + doCompleteFirstRun, + doShowSuggestedSubs, } from 'redux/actions/subscriptions'; import { doSetClientSetting } from 'redux/actions/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings'; @@ -26,14 +30,18 @@ const select = state => ({ allSubscriptions: selectSubscriptionClaims(state), unreadSubscriptions: selectUnreadSubscriptions(state), viewMode: selectViewMode(state), + firstRunCompleted: selectFirstRunCompleted(state), + showSuggestedSubs: selectshowSuggestedSubs(state), }); export default connect( select, { - doUpdateUnreadSubscriptions, doFetchMySubscriptions, doSetClientSetting, doSetViewMode, + doFetchRecommendedSubscriptions, + doCompleteFirstRun, + doShowSuggestedSubs, } )(SubscriptionsPage); diff --git a/src/renderer/page/subscriptions/internal/first-run.jsx b/src/renderer/page/subscriptions/internal/first-run.jsx new file mode 100644 index 000000000..12771bdda --- /dev/null +++ b/src/renderer/page/subscriptions/internal/first-run.jsx @@ -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> + ); +}; diff --git a/src/renderer/page/subscriptions/internal/user-subscriptions.jsx b/src/renderer/page/subscriptions/internal/user-subscriptions.jsx new file mode 100644 index 000000000..c1df738c4 --- /dev/null +++ b/src/renderer/page/subscriptions/internal/user-subscriptions.jsx @@ -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> + ); +}; diff --git a/src/renderer/page/subscriptions/view.jsx b/src/renderer/page/subscriptions/view.jsx index 5b7b80578..5359a1370 100644 --- a/src/renderer/page/subscriptions/view.jsx +++ b/src/renderer/page/subscriptions/view.jsx @@ -1,16 +1,11 @@ // @flow import type { ViewMode } from 'types/subscription'; import type { Claim } from 'types/claim'; -import { VIEW_ALL, VIEW_LATEST_FIRST } from 'constants/subscriptions'; -import * as settings from 'constants/settings'; -import * as React from 'react'; +import * as SETTINGS from 'constants/settings'; +import React, { PureComponent } from 'react'; import Page from 'component/page'; -import Button from 'component/button'; -import FileList from 'component/fileList'; -import HiddenNsfwClaims from 'component/hiddenNsfwClaims'; -import { FormField } from 'component/common/form'; -import FileCard from 'component/fileCard'; -import { parseURI } from 'lbry-redux'; +import FirstRun from './internal/first-run'; +import UserSubscriptions from './internal/user-subscriptions'; type Props = { subscribedChannels: Array<string>, // The channels a user is subscribed to @@ -25,9 +20,15 @@ type Props = { doSetViewMode: ViewMode => void, doFetchMySubscriptions: () => 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() { super(); @@ -35,56 +36,26 @@ export default class extends React.PureComponent<Props> { } componentDidMount() { - const { doFetchMySubscriptions } = this.props; + const { + doFetchMySubscriptions, + doFetchRecommendedSubscriptions, + allSubscriptions, + firstRunCompleted, + doShowSuggestedSubs, + } = this.props; 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<*>) { - 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> - ); + this.props.doSetClientSetting(SETTINGS.AUTO_DOWNLOAD, event.target.checked); } render() { @@ -95,58 +66,39 @@ export default class extends React.PureComponent<Props> { autoDownload, viewMode, doSetViewMode, + loadingSuggested, + firstRunCompleted, + doCompleteFirstRun, + doShowSuggestedSubs, + showSuggestedSubs, + unreadSubscriptions, } = this.props; + const numberOfSubscriptions = subscribedChannels && subscribedChannels.length; return ( // Only pass in the loading prop if there are no subscriptions // If there are any, let the page update in the background // The loading prop removes children and shows a loading spinner <Page notContained loading={loading && !subscribedChannels}> - <HiddenNsfwClaims - uris={allSubscriptions.reduce((arr, { name, claim_id: claimId }) => { - if (name && claimId) { - arr.push(`lbry://${name}#${claimId}`); - } - return arr; - }, [])} - /> - {!!subscribedChannels.length && ( - <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={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> + {firstRunCompleted ? ( + <UserSubscriptions + viewMode={viewMode} + doSetViewMode={doSetViewMode} + hasSubscriptions={numberOfSubscriptions > 0} + subscriptions={allSubscriptions} + autoDownload={autoDownload} + onChangeAutoDownload={this.onAutoDownloadChange} + unreadSubscriptions={unreadSubscriptions} + loadingSuggested={loadingSuggested} + /> + ) : ( + <FirstRun + showSuggested={showSuggestedSubs} + doShowSuggestedSubs={doShowSuggestedSubs} + loadingSuggested={loadingSuggested} + numberOfSubscriptions={numberOfSubscriptions} + onFinish={doCompleteFirstRun} + /> )} </Page> ); diff --git a/src/renderer/redux/actions/content.js b/src/renderer/redux/actions/content.js index 76b26b722..8a76e8213 100644 --- a/src/renderer/redux/actions/content.js +++ b/src/renderer/redux/actions/content.js @@ -24,11 +24,11 @@ import { makeSelectChannelForClaimUri, parseURI, creditsToString, - doError + doError, } from 'lbry-redux'; import { makeSelectClientSetting, selectosNotificationsEnabled } from 'redux/selectors/settings'; -import setBadge from 'util/setBadge'; -import setProgressBar from 'util/setProgressBar'; +import setBadge from 'util/set-badge'; +import setProgressBar from 'util/set-progress-bar'; import analytics from 'analytics'; const DOWNLOAD_POLL_INTERVAL = 250; diff --git a/src/renderer/redux/actions/file.js b/src/renderer/redux/actions/file.js index b643c3d38..f10e9ddf9 100644 --- a/src/renderer/redux/actions/file.js +++ b/src/renderer/redux/actions/file.js @@ -10,7 +10,7 @@ import { } from 'lbry-redux'; import { doHideModal } from 'redux/actions/app'; import { doHistoryBack } from 'redux/actions/navigation'; -import setProgressBar from 'util/setProgressBar'; +import setProgressBar from 'util/set-progress-bar'; export function doOpenFileInFolder(path) { return () => { diff --git a/src/renderer/redux/actions/navigation.js b/src/renderer/redux/actions/navigation.js index 5c833feb4..1e331c00a 100644 --- a/src/renderer/redux/actions/navigation.js +++ b/src/renderer/redux/actions/navigation.js @@ -1,5 +1,5 @@ import { ACTIONS, selectHistoryIndex, selectHistoryStack } from 'lbry-redux'; -import { toQueryString } from 'util/query_params'; +import { toQueryString } from 'util/query-params'; import analytics from 'analytics'; export function doNavigate(path, params = {}, options = {}) { diff --git a/src/renderer/redux/actions/subscriptions.js b/src/renderer/redux/actions/subscriptions.js index 8e8f8445e..d01f1adbc 100644 --- a/src/renderer/redux/actions/subscriptions.js +++ b/src/renderer/redux/actions/subscriptions.js @@ -394,3 +394,33 @@ export const doCheckSubscriptionsInit = () => (dispatch: ReduxDispatch) => { 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, + }); diff --git a/src/renderer/redux/reducers/subscriptions.js b/src/renderer/redux/reducers/subscriptions.js index 711739ded..675cf29be 100644 --- a/src/renderer/redux/reducers/subscriptions.js +++ b/src/renderer/redux/reducers/subscriptions.js @@ -12,13 +12,18 @@ import type { DoRemoveSubscriptionUnreads, FetchedSubscriptionsSucess, SetViewMode, + GetSuggestedSubscriptionsSuccess, } from 'types/subscription'; const defaultState: SubscriptionState = { subscriptions: [], unread: {}, + suggested: {}, loading: false, viewMode: VIEW_ALL, + loadingSuggested: false, + firstRunCompleted: false, + showSuggestedSubs: false, }; export default handleActions( @@ -127,6 +132,30 @@ export default handleActions( ...state, 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 ); diff --git a/src/renderer/redux/selectors/subscriptions.js b/src/renderer/redux/selectors/subscriptions.js index 84030509c..873e4ade2 100644 --- a/src/renderer/redux/selectors/subscriptions.js +++ b/src/renderer/redux/selectors/subscriptions.js @@ -1,3 +1,4 @@ +import { SUGGESTED_FEATURED, SUGGESTED_TOP_SUBSCRIBED } from 'constants/subscriptions'; import { createSelector } from 'reselect'; import { selectAllClaimsByChannel, @@ -7,6 +8,7 @@ import { selectClaimsByUri, parseURI, } from 'lbry-redux'; +import { swapKeyAndValue } from 'util/swap-json'; // Returns the entire subscriptions state const selectState = state => state.subscriptions || {}; @@ -20,6 +22,72 @@ export const selectIsFetchingSubscriptions = createSelector(selectState, state = // The current view mode on the subscriptions page 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 export const selectSubscriptionsBeingFetched = createSelector( selectSubscriptions, diff --git a/src/renderer/scss/_gui.scss b/src/renderer/scss/_gui.scss index a60c78bf5..dc1463b9c 100644 --- a/src/renderer/scss/_gui.scss +++ b/src/renderer/scss/_gui.scss @@ -192,9 +192,20 @@ p:not(:first-of-type) { display: flex; flex-direction: column; margin-top: 200px; + margin-bottom: 100px; 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 { display: flex; justify-content: space-between; diff --git a/src/renderer/scss/all.scss b/src/renderer/scss/all.scss index 8fa28771a..7c12c5a29 100644 --- a/src/renderer/scss/all.scss +++ b/src/renderer/scss/all.scss @@ -6,4 +6,5 @@ 'component/markdown-editor', 'component/scrollbar', 'component/spinner', 'component/nav', 'component/file-list', 'component/file-render', 'component/search', 'component/toggle', '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'; diff --git a/src/renderer/scss/component/_subscriptions.scss b/src/renderer/scss/component/_subscriptions.scss new file mode 100644 index 000000000..e815a9e39 --- /dev/null +++ b/src/renderer/scss/component/_subscriptions.scss @@ -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; + } +} diff --git a/src/renderer/store.js b/src/renderer/store.js index 1e3f2b990..a8ab129c9 100644 --- a/src/renderer/store.js +++ b/src/renderer/store.js @@ -101,7 +101,6 @@ const compressor = createCompressor(); // 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 // const saveClaimsFilter = createFilter('claims', ['byId', 'claimsByUri']); -const subscriptionsFilter = createFilter('subscriptions', ['subscriptions', 'unread', 'viewMode']); const contentFilter = createFilter('content', ['positions', 'history']); const fileInfoFilter = createFilter('fileInfo', [ 'fileListPublishedSort', @@ -116,14 +115,7 @@ const persistOptions = { whitelist: ['subscriptions', 'publish', 'wallet', 'content', 'fileInfo', 'app'], // Order is important. Needs to be compressed last or other transforms can't // read the data - transforms: [ - subscriptionsFilter, - walletFilter, - contentFilter, - fileInfoFilter, - appFilter, - compressor, - ], + transforms: [walletFilter, contentFilter, fileInfoFilter, appFilter, compressor], debounce: 10000, storage: localForage, }; diff --git a/src/renderer/types/subscription.js b/src/renderer/types/subscription.js index 4970b16b8..8fd3bdc40 100644 --- a/src/renderer/types/subscription.js +++ b/src/renderer/types/subscription.js @@ -7,6 +7,9 @@ import { NOTIFY_ONLY, VIEW_ALL, VIEW_LATEST_FIRST, + SUGGESTED_TOP_BID, + SUGGESTED_TOP_SUBSCRIBED, + SUGGESTED_FEATURED, } from 'constants/subscriptions'; export type Subscription = { @@ -31,11 +34,21 @@ export type UnreadSubscriptions = { 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 = { subscriptions: Array<Subscription>, unread: UnreadSubscriptions, loading: boolean, viewMode: ViewMode, + suggested: SuggestedSubscriptions, + loadingSuggested: boolean, + firstRunCompleted: boolean, + showSuggestedSubs: boolean, }; // @@ -94,6 +107,11 @@ export type SetViewMode = { data: ViewMode, }; +export type GetSuggestedSubscriptionsSuccess = { + type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_START, + data: SuggestedSubscriptions, +}; + export type Action = | DoChannelSubscribe | DoChannelUnsubscribe diff --git a/src/renderer/util/contextMenu.js b/src/renderer/util/context-menu.js similarity index 100% rename from src/renderer/util/contextMenu.js rename to src/renderer/util/context-menu.js diff --git a/src/renderer/util/formatCredits.js b/src/renderer/util/format-credits.js similarity index 100% rename from src/renderer/util/formatCredits.js rename to src/renderer/util/format-credits.js diff --git a/src/renderer/util/getMediaType.js b/src/renderer/util/get-media-type.js similarity index 100% rename from src/renderer/util/getMediaType.js rename to src/renderer/util/get-media-type.js diff --git a/src/renderer/util/parseData.js b/src/renderer/util/parse-data.js similarity index 100% rename from src/renderer/util/parseData.js rename to src/renderer/util/parse-data.js diff --git a/src/renderer/util/query_params.js b/src/renderer/util/query-params.js similarity index 100% rename from src/renderer/util/query_params.js rename to src/renderer/util/query-params.js diff --git a/src/renderer/util/setBadge.js b/src/renderer/util/set-badge.js similarity index 100% rename from src/renderer/util/setBadge.js rename to src/renderer/util/set-badge.js diff --git a/src/renderer/util/setProgressBar.js b/src/renderer/util/set-progress-bar.js similarity index 100% rename from src/renderer/util/setProgressBar.js rename to src/renderer/util/set-progress-bar.js diff --git a/src/renderer/util/swap-json.js b/src/renderer/util/swap-json.js new file mode 100644 index 000000000..6b52a9a64 --- /dev/null +++ b/src/renderer/util/swap-json.js @@ -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; +} diff --git a/static/img/gerbil-happy.png b/static/img/gerbil-happy.png new file mode 100644 index 000000000..774f62cbe Binary files /dev/null and b/static/img/gerbil-happy.png differ diff --git a/static/img/gerbil-sad.png b/static/img/gerbil-sad.png new file mode 100644 index 000000000..cc11a5bce Binary files /dev/null and b/static/img/gerbil-sad.png differ