Subscriptions overhaul #1872

Merged
daovist merged 13 commits from subscriptions-overhaul into master 2018-08-20 18:36:23 +02:00
14 changed files with 168 additions and 116 deletions

View file

@ -25,10 +25,7 @@ type Props = {
const SideBar = (props: Props) => { const SideBar = (props: Props) => {
const { navLinks, notifications } = props; const { navLinks, notifications } = props;
const badges = Object.keys(notifications).reduce( const badges = Object.keys(notifications).length;
(acc, cur) => (notifications[cur].type === NOTIFICATION_TYPES.DOWNLOADING ? acc : acc + 1),
0
);
return ( return (
<nav className="nav"> <nav className="nav">

View file

@ -15,3 +15,4 @@ export const AUTOMATIC_DARK_MODE_ENABLED = 'automaticDarkModeEnabled';
export const AUTOPLAY = 'autoplay'; export const AUTOPLAY = 'autoplay';
export const RESULT_COUNT = 'resultCount'; export const RESULT_COUNT = 'resultCount';
export const OS_NOTIFICATIONS_ENABLED = 'osNotificationsEnabled'; export const OS_NOTIFICATIONS_ENABLED = 'osNotificationsEnabled';
export const AUTO_DOWNLOAD = 'autoDownload';

View file

@ -40,7 +40,7 @@ const perform = dispatch => ({
navigate: (path, params) => dispatch(doNavigate(path, params)), navigate: (path, params) => dispatch(doNavigate(path, params)),
fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)), fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)),
fetchCostInfo: uri => dispatch(doFetchCostInfoForUri(uri)), fetchCostInfo: uri => dispatch(doFetchCostInfoForUri(uri)),
checkSubscription: subscription => dispatch(doCheckSubscription(subscription)), checkSubscription: uri => dispatch(doCheckSubscription(uri)),
openModal: (modal, props) => dispatch(doNotify(modal, props)), openModal: (modal, props) => dispatch(doNotify(modal, props)),
prepareEdit: (publishData, uri) => dispatch(doPrepareEdit(publishData, uri)), prepareEdit: (publishData, uri) => dispatch(doPrepareEdit(publishData, uri)),
setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)), setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)),

View file

@ -44,7 +44,7 @@ type Props = {
fetchFileInfo: string => void, fetchFileInfo: string => void,
fetchCostInfo: string => void, fetchCostInfo: string => void,
prepareEdit: ({}, string) => void, prepareEdit: ({}, string) => void,
checkSubscription: ({ channelName: string, uri: string }) => void, checkSubscription: (uri: string) => void,
subscriptions: Array<Subscription>, subscriptions: Array<Subscription>,
setClientSetting: (string, boolean | string) => void, setClientSetting: (string, boolean | string) => void,
autoplay: boolean, autoplay: boolean,
@ -96,16 +96,12 @@ class FilePage extends React.Component<Props> {
checkSubscription = (props: Props) => { checkSubscription = (props: Props) => {
if (props.subscriptions.find(sub => sub.channelName === props.claim.channel_name)) { if (props.subscriptions.find(sub => sub.channelName === props.claim.channel_name)) {
props.checkSubscription({ props.checkSubscription(
channelName: props.claim.channel_name, buildURI({
uri: buildURI(
{
contentName: props.claim.channel_name, contentName: props.claim.channel_name,
claimId: props.claim.value.publisherSignature.certificateId, claimId: props.claim.value.publisherSignature.certificateId,
}, }, false)
false );
),
});
} }
}; };

View file

@ -30,6 +30,7 @@ const select = state => ({
autoplay: makeSelectClientSetting(settings.AUTOPLAY)(state), autoplay: makeSelectClientSetting(settings.AUTOPLAY)(state),
walletEncrypted: selectWalletIsEncrypted(state), walletEncrypted: selectWalletIsEncrypted(state),
osNotificationsEnabled: selectosNotificationsEnabled(state), osNotificationsEnabled: selectosNotificationsEnabled(state),
autoDownload: makeSelectClientSetting(settings.AUTO_DOWNLOAD)(state),
}); });
const perform = dispatch => ({ const perform = dispatch => ({

View file

@ -31,6 +31,7 @@ type Props = {
themes: Array<string>, themes: Array<string>,
automaticDarkModeEnabled: boolean, automaticDarkModeEnabled: boolean,
autoplay: boolean, autoplay: boolean,
autoDownload: boolean,
encryptWallet: () => void, encryptWallet: () => void,
decryptWallet: () => void, decryptWallet: () => void,
walletEncrypted: boolean, walletEncrypted: boolean,
@ -59,6 +60,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
(this: any).onAutoplayChange = this.onAutoplayChange.bind(this); (this: any).onAutoplayChange = this.onAutoplayChange.bind(this);
(this: any).clearCache = this.clearCache.bind(this); (this: any).clearCache = this.clearCache.bind(this);
(this: any).onDesktopNotificationsChange = this.onDesktopNotificationsChange.bind(this); (this: any).onDesktopNotificationsChange = this.onDesktopNotificationsChange.bind(this);
(this: any).onAutoDownloadChange = this.onAutoDownloadChange.bind(this);
// (this: any).onLanguageChange = this.onLanguageChange.bind(this) // (this: any).onLanguageChange = this.onLanguageChange.bind(this)
} }
@ -119,6 +121,10 @@ class SettingsPage extends React.PureComponent<Props, State> {
this.props.setClientSetting(settings.SHOW_NSFW, event.target.checked); this.props.setClientSetting(settings.SHOW_NSFW, event.target.checked);
} }
onAutoDownloadChange(event: SyntheticInputEvent<*>) {
this.props.setClientSetting(settings.AUTO_DOWNLOAD, event.target.checked);
}
onChangeEncryptWallet() { onChangeEncryptWallet() {
const { props } = this; const { props } = this;
props.walletEncrypted ? props.decryptWallet() : props.encryptWallet(); props.walletEncrypted ? props.decryptWallet() : props.encryptWallet();
@ -157,6 +163,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
autoplay, autoplay,
walletEncrypted, walletEncrypted,
osNotificationsEnabled, osNotificationsEnabled,
autoDownload,
} = this.props; } = this.props;
const noDaemonSettings = !daemonSettings || Object.keys(daemonSettings).length === 0; const noDaemonSettings = !daemonSettings || Object.keys(daemonSettings).length === 0;
@ -265,6 +272,13 @@ class SettingsPage extends React.PureComponent<Props, State> {
checked={autoplay} checked={autoplay}
postfix={__('Autoplay media files')} postfix={__('Autoplay media files')}
/> />
<FormField
type="checkbox"
name="auto_download"
onChange={this.onAutoDownloadChange}
checked={autoDownload}
postfix={__('Automatically download new content from your subscriptions')}
/>
<FormField <FormField
type="checkbox" type="checkbox"
name="show_nsfw" name="show_nsfw"

View file

@ -25,6 +25,9 @@ export default class extends React.PureComponent<Props> {
const { notifications, setSubscriptionNotifications, doFetchMySubscriptions } = this.props; const { notifications, setSubscriptionNotifications, doFetchMySubscriptions } = this.props;
doFetchMySubscriptions(); doFetchMySubscriptions();
// @sean will change this behavior when implementing new content labeling
// notifications should be cleared individually
// do we want a way to clear individual claims without viewing?
const newNotifications = {}; const newNotifications = {};
Object.keys(notifications).forEach(cur => { Object.keys(notifications).forEach(cur => {
if (notifications[cur].type === NOTIFICATION_TYPES.DOWNLOADING) { if (notifications[cur].type === NOTIFICATION_TYPES.DOWNLOADING) {

View file

@ -18,7 +18,7 @@ import { doFetchDaemonSettings } from 'redux/actions/settings';
import { doAuthNavigate } from 'redux/actions/navigation'; import { doAuthNavigate } from 'redux/actions/navigation';
import { doAuthenticate } from 'redux/actions/user'; import { doAuthenticate } from 'redux/actions/user';
import { doPause } from 'redux/actions/media'; import { doPause } from 'redux/actions/media';
import { doCheckSubscriptions } from 'redux/actions/subscriptions'; import { doCheckSubscriptionsInit } from 'redux/actions/subscriptions';
import { import {
selectIsUpgradeSkipped, selectIsUpgradeSkipped,
selectUpdateUrl, selectUpdateUrl,
@ -347,7 +347,7 @@ export function doDaemonReady() {
dispatch(doCheckUpgradeAvailable()); dispatch(doCheckUpgradeAvailable());
} }
dispatch(doCheckUpgradeSubscribe()); dispatch(doCheckUpgradeSubscribe());
dispatch(doCheckSubscriptions()); dispatch(doCheckSubscriptionsInit());
}; };
} }

View file

@ -110,6 +110,7 @@ export function doUpdateLoadStatus(uri, outpoint) {
dispatch(doUpdateLoadStatus(uri, outpoint)); dispatch(doUpdateLoadStatus(uri, outpoint));
}, DOWNLOAD_POLL_INTERVAL); }, DOWNLOAD_POLL_INTERVAL);
} else if (fileInfo.completed) { } else if (fileInfo.completed) {
const state = getState();
// TODO this isn't going to get called if they reload the client before // TODO this isn't going to get called if they reload the client before
// the download finished // the download finished
dispatch({ dispatch({
@ -121,13 +122,13 @@ export function doUpdateLoadStatus(uri, outpoint) {
}, },
}); });
const badgeNumber = selectBadgeNumber(getState()); const badgeNumber = selectBadgeNumber(state);
setBadge(badgeNumber === 0 ? '' : `${badgeNumber}`); setBadge(badgeNumber === 0 ? '' : `${badgeNumber}`);
const totalProgress = selectTotalDownloadProgress(getState()); const totalProgress = selectTotalDownloadProgress(state);
setProgressBar(totalProgress); setProgressBar(totalProgress);
const notifications = selectNotifications(getState()); const notifications = selectNotifications(state);
if (notifications[uri] && notifications[uri].type === NOTIFICATION_TYPES.DOWNLOADING) { if (notifications[uri] && notifications[uri].type === NOTIFICATION_TYPES.DOWNLOADING) {
const count = Object.keys(notifications).reduce( const count = Object.keys(notifications).reduce(
(acc, cur) => (acc, cur) =>
@ -138,7 +139,7 @@ export function doUpdateLoadStatus(uri, outpoint) {
0 0
neb-b commented 2018-08-14 02:35:36 +02:00 (Migrated from github.com)
Review

I think the idea behind snackbars is that they are reserved for user actions, which wouldn't make sense for this use case. Maybe we should create our own notification component that mimics desktop notifications but has some additional style.

Not needed for this PR but I think we should stick with the OS notifications for now.

I think the idea behind snackbars is that they are reserved for user actions, which wouldn't make sense for this use case. Maybe we should create our own notification component that mimics desktop notifications but has some additional style. Not needed for this PR but I think we should stick with the OS notifications for now.
); );
if (selectosNotificationsEnabled(getState())) { if (selectosNotificationsEnabled(state)) {
const notif = new window.Notification(notifications[uri].subscription.channelName, { const notif = new window.Notification(notifications[uri].subscription.channelName, {
body: `Posted ${fileInfo.metadata.title}${ body: `Posted ${fileInfo.metadata.title}${
count > 1 && count < 10 ? ` and ${count - 1} other new items` : '' count > 1 && count < 10 ? ` and ${count - 1} other new items` : ''
@ -153,6 +154,7 @@ export function doUpdateLoadStatus(uri, outpoint) {
); );
}; };
} }
if (state.navigation.currentPath !== '/subscriptions') {
dispatch( dispatch(
setSubscriptionNotification( setSubscriptionNotification(
notifications[uri].subscription, notifications[uri].subscription,
@ -160,6 +162,7 @@ export function doUpdateLoadStatus(uri, outpoint) {
NOTIFICATION_TYPES.DOWNLOADED NOTIFICATION_TYPES.DOWNLOADED
) )
); );
}
} else { } else {
// If notifications are disabled(false) just return // If notifications are disabled(false) just return
if (!selectosNotificationsEnabled(getState())) return; if (!selectosNotificationsEnabled(getState())) return;
@ -376,17 +379,18 @@ export function doFetchClaimsByChannel(uri, page) {
buildURI({ contentName: latest.name, claimId: latest.claim_id }, false) buildURI({ contentName: latest.name, claimId: latest.claim_id }, false)
) )
); );
const notifications = selectNotifications(getState()); // commented out as a note for @sean, notification will be clared individually
const newNotifications = {}; // const notifications = selectNotifications(getState());
Object.keys(notifications).forEach(cur => { // const newNotifications = {};
if ( // Object.keys(notifications).forEach(cur => {
notifications[cur].subscription.channelName !== latest.channel_name || // if (
notifications[cur].type === NOTIFICATION_TYPES.DOWNLOADING // notifications[cur].subscription.channelName !== latest.channel_name ||
) { // notifications[cur].type === NOTIFICATION_TYPES.DOWNLOADING
newNotifications[cur] = { ...notifications[cur] }; // ) {
} // newNotifications[cur] = { ...notifications[cur] };
}); // }
dispatch(setSubscriptionNotifications(newNotifications)); // });
// dispatch(setSubscriptionNotifications(newNotifications));
} }
dispatch({ dispatch({

View file

@ -40,11 +40,13 @@ export function doClaimRewardType(rewardType, options) {
} }
if (!userIsRewardApproved && rewardType !== rewards.TYPE_CONFIRM_EMAIL) { if (!userIsRewardApproved && rewardType !== rewards.TYPE_CONFIRM_EMAIL) {
if (!options || !options.failSilently) {
const action = doNotify({ const action = doNotify({
id: MODALS.REWARD_APPROVAL_REQUIRED, id: MODALS.REWARD_APPROVAL_REQUIRED,
isError: false, isError: false,
}); });
dispatch(action); dispatch(action);
}
return; return;
} }

View file

@ -1,19 +1,19 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import * as NOTIFICATION_TYPES from 'constants/notification_types'; import * as NOTIFICATION_TYPES from 'constants/notification_types';
import type { import * as SETTINGS from 'constants/settings';
Dispatch, import rewards from 'rewards';
SubscriptionState, import type { Dispatch, SubscriptionNotifications } from 'redux/reducers/subscriptions';
SubscriptionNotifications,
} from 'redux/reducers/subscriptions';
import type { Subscription } from 'types/subscription'; import type { Subscription } from 'types/subscription';
import { selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectSubscriptions } from 'redux/selectors/subscriptions';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { Lbry, buildURI, parseURI } from 'lbry-redux'; import { Lbry, buildURI, parseURI } from 'lbry-redux';
import { doPurchaseUri } from 'redux/actions/content'; import { doPurchaseUri } from 'redux/actions/content';
import { doClaimRewardType } from 'redux/actions/rewards';
import Promise from 'bluebird'; import Promise from 'bluebird';
import Lbryio from 'lbryio'; import Lbryio from 'lbryio';
const CHECK_SUBSCRIPTIONS_INTERVAL = 60 * 60 * 1000; const CHECK_SUBSCRIPTIONS_INTERVAL = 15 * 60 * 1000;
const SUBSCRIPTION_DOWNLOAD_LIMIT = 1; const SUBSCRIPTION_DOWNLOAD_LIMIT = 1;
export const doFetchMySubscriptions = () => (dispatch: Dispatch, getState: () => any) => { export const doFetchMySubscriptions = () => (dispatch: Dispatch, getState: () => any) => {
@ -124,40 +124,55 @@ export const setSubscriptionNotification = (
}, },
}); });
export const doCheckSubscription = (subscription: Subscription, notify?: boolean) => ( export const doCheckSubscription = (subscriptionUri: string, notify?: boolean) => (
dispatch: Dispatch dispatch: Dispatch,
getState: () => {}
) => { ) => {
dispatch({ // no dispatching FETCH_CHANNEL_CLAIMS_STARTED; causes loading issues on <SubscriptionsPage>
type: ACTIONS.CHECK_SUBSCRIPTION_STARTED,
data: subscription,
});
Lbry.claim_list_by_channel({ uri: subscription.uri, page: 1 }).then(result => { const state = getState();
const claimResult = result[subscription.uri] || {}; const savedSubscription = state.subscriptions.subscriptions.find(
sub => sub.uri === subscriptionUri
);
Lbry.claim_list_by_channel({ uri: subscriptionUri, page: 1 }).then(result => {
const claimResult = result[subscriptionUri] || {};
const { claims_in_channel: claimsInChannel } = claimResult; const { claims_in_channel: claimsInChannel } = claimResult;
if (claimsInChannel) { const latestIndex = claimsInChannel.findIndex(
claim => `${claim.name}#${claim.claim_id}` === savedSubscription.latest
);
// if latest is 0, nothing has changed
// when there is no subscription latest, it is either a newly subscriubed channel or
// the user has cleared their cache. Either way, do not download or notify about new content
// as that would download/notify 10 claims per channel
if (claimsInChannel.length && latestIndex !== 0 && savedSubscription.latest) {
let downloadCount = 0;
claimsInChannel.slice(0, latestIndex === -1 ? 10 : latestIndex).forEach(claim => {
const uri = buildURI({ contentName: claim.name, claimId: claim.claim_id }, false);
const shouldDownload = Boolean(
downloadCount < SUBSCRIPTION_DOWNLOAD_LIMIT &&
!claim.value.stream.metadata.fee &&
makeSelectClientSetting(SETTINGS.AUTO_DOWNLOAD)(state)
);
if (notify) { if (notify) {
claimsInChannel.reduce((prev, cur, index) => {
const uri = buildURI({ contentName: cur.name, claimId: cur.claim_id }, false);
if (prev === -1 && uri !== subscription.latest) {
dispatch( dispatch(
setSubscriptionNotification( setSubscriptionNotification(
subscription, savedSubscription,
uri, uri,
index < SUBSCRIPTION_DOWNLOAD_LIMIT && !cur.value.stream.metadata.fee shouldDownload ? NOTIFICATION_TYPES.DOWNLOADING : NOTIFICATION_TYPES.NOTIFY_ONLY
? NOTIFICATION_TYPES.DOWNLOADING
: NOTIFICATION_TYPES.NOTIFY_ONLY
) )
); );
if (index < SUBSCRIPTION_DOWNLOAD_LIMIT && !cur.value.stream.metadata.fee) { }
if (shouldDownload) {
downloadCount += 1;
dispatch(doPurchaseUri(uri, { cost: 0 })); dispatch(doPurchaseUri(uri, { cost: 0 }));
} }
} });
return uri === subscription.latest || !subscription.latest ? index : prev;
}, -1);
} }
// always setLatest; important for newly subscribed channels
dispatch( dispatch(
setSubscriptionLatest( setSubscriptionLatest(
{ {
@ -176,11 +191,16 @@ export const doCheckSubscription = (subscription: Subscription, notify?: boolean
) )
) )
); );
}
// calling FETCH_CHANNEL_CLAIMS_COMPLETED after not calling STARTED
// means it will delete a non-existant fetchingChannelClaims[uri]
dispatch({ dispatch({
type: ACTIONS.CHECK_SUBSCRIPTION_COMPLETED, type: ACTIONS.FETCH_CHANNEL_CLAIMS_COMPLETED,
data: subscription, data: {
uri: subscriptionUri,
claims: claimsInChannel || [],
page: 1,
},
}); });
}); });
}; };
@ -217,8 +237,11 @@ export const doChannelSubscribe = (subscription: Subscription) => (
channel_name: subscription.channelName, channel_name: subscription.channelName,
claim_id: claimId, claim_id: claimId,
}); });
dispatch(doClaimRewardType(rewards.SUBSCRIPTION, { failSilently: true }));
} }
// should be subUri
dispatch(doCheckSubscription(subscription, true)); dispatch(doCheckSubscription(subscription, true));
}; };
@ -244,15 +267,22 @@ export const doChannelUnsubscribe = (subscription: Subscription) => (
} }
}; };
export const doCheckSubscriptions = () => ( export const doCheckSubscriptions = () => (dispatch: Dispatch, getState: () => any) => {
dispatch: Dispatch, const state = getState();
getState: () => SubscriptionState const subscriptions = selectSubscriptions(state);
) => { subscriptions.forEach((sub: Subscription) => {
dispatch(doCheckSubscription(sub.uri, true));
});
};
export const doCheckSubscriptionsInit = () => (dispatch: Dispatch) => {
// doCheckSubscriptionsInit is called by doDaemonReady
// setTimeout below is a hack to ensure redux is hydrated when subscriptions are checked
// this will be replaced with <PersistGate> which reqiures a package upgrade
setTimeout(() => dispatch(doFetchMySubscriptions()), 5000);
setTimeout(() => dispatch(doCheckSubscriptions()), 10000);
const checkSubscriptionsTimer = setInterval( const checkSubscriptionsTimer = setInterval(
() => () => dispatch(doCheckSubscriptions()),
selectSubscriptions(getState()).map((subscription: Subscription) =>
dispatch(doCheckSubscription(subscription, true))
),
CHECK_SUBSCRIPTIONS_INTERVAL CHECK_SUBSCRIPTIONS_INTERVAL
); );
dispatch({ dispatch({

View file

@ -26,6 +26,7 @@ const defaultState = {
automaticDarkModeEnabled: getLocalStorageSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false), automaticDarkModeEnabled: getLocalStorageSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false),
autoplay: getLocalStorageSetting(SETTINGS.AUTOPLAY, false), autoplay: getLocalStorageSetting(SETTINGS.AUTOPLAY, false),
resultCount: Number(getLocalStorageSetting(SETTINGS.RESULT_COUNT, 50)), resultCount: Number(getLocalStorageSetting(SETTINGS.RESULT_COUNT, 50)),
autoDownload: getLocalStorageSetting(SETTINGS.AUTO_DOWNLOAD, true),
osNotificationsEnabled: Boolean( osNotificationsEnabled: Boolean(
getLocalStorageSetting(SETTINGS.OS_NOTIFICATIONS_ENABLED, true) getLocalStorageSetting(SETTINGS.OS_NOTIFICATIONS_ENABLED, true)
), ),

View file

@ -25,7 +25,7 @@ export const selectSubscriptionClaims = createSelector(
return []; return [];
} }
const fetchedSubscriptions = []; let fetchedSubscriptions = [];
savedSubscriptions.forEach(subscription => { savedSubscriptions.forEach(subscription => {
let channelClaims = []; let channelClaims = [];
@ -39,18 +39,20 @@ export const selectSubscriptionClaims = createSelector(
// loop over the list of ids and grab the claim // loop over the list of ids and grab the claim
pageOneChannelIds.forEach(id => { pageOneChannelIds.forEach(id => {
const grabbedClaim = allClaims[id]; const grabbedClaim = allClaims[id];
channelClaims.push(grabbedClaim); channelClaims = channelClaims.concat([grabbedClaim]);
}); });
} }
fetchedSubscriptions.push({ fetchedSubscriptions = fetchedSubscriptions.concat([
claims: channelClaims, {
claims: [...channelClaims],
channelName: subscription.channelName, channelName: subscription.channelName,
uri: subscription.uri, uri: subscription.uri,
}); },
]);
}); });
return fetchedSubscriptions; return [...fetchedSubscriptions];
} }
); );

View file

@ -13,6 +13,7 @@ rewards.TYPE_FIRST_PUBLISH = 'first_publish';
rewards.TYPE_FEATURED_DOWNLOAD = 'featured_download'; rewards.TYPE_FEATURED_DOWNLOAD = 'featured_download';
rewards.TYPE_REFERRAL = 'referral'; rewards.TYPE_REFERRAL = 'referral';
rewards.YOUTUBE_CREATOR = 'youtube_creator'; rewards.YOUTUBE_CREATOR = 'youtube_creator';
rewards.SUBSCRIPTION = 'subscription';
rewards.claimReward = type => { rewards.claimReward = type => {
function requestReward(resolve, reject, params) { function requestReward(resolve, reject, params) {