Common subscriptions implementation (#19)
* add subscriptions actions, reducer and selectors, with related methods
This commit is contained in:
parent
60d8040189
commit
4fca4fabb8
13 changed files with 8366 additions and 8 deletions
7134
dist/bundle.js
vendored
7134
dist/bundle.js
vendored
File diff suppressed because it is too large
Load diff
|
@ -29,6 +29,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lbry-redux": "lbryio/lbry-redux#84b7d396934d57a37802aadbef71db91230a9404",
|
"lbry-redux": "lbryio/lbry-redux#84b7d396934d57a37802aadbef71db91230a9404",
|
||||||
|
"bluebird": "^3.5.1",
|
||||||
"reselect": "^3.0.0"
|
"reselect": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -1,3 +1,54 @@
|
||||||
|
// Auth Token
|
||||||
export const GENERATE_AUTH_TOKEN_FAILURE = 'GENERATE_AUTH_TOKEN_FAILURE';
|
export const GENERATE_AUTH_TOKEN_FAILURE = 'GENERATE_AUTH_TOKEN_FAILURE';
|
||||||
export const GENERATE_AUTH_TOKEN_STARTED = 'GENERATE_AUTH_TOKEN_STARTED';
|
export const GENERATE_AUTH_TOKEN_STARTED = 'GENERATE_AUTH_TOKEN_STARTED';
|
||||||
export const GENERATE_AUTH_TOKEN_SUCCESS = 'GENERATE_AUTH_TOKEN_SUCCESS';
|
export const GENERATE_AUTH_TOKEN_SUCCESS = 'GENERATE_AUTH_TOKEN_SUCCESS';
|
||||||
|
|
||||||
|
// Claims
|
||||||
|
export const FETCH_FEATURED_CONTENT_STARTED = 'FETCH_FEATURED_CONTENT_STARTED';
|
||||||
|
export const FETCH_FEATURED_CONTENT_COMPLETED = 'FETCH_FEATURED_CONTENT_COMPLETED';
|
||||||
|
export const RESOLVE_URIS_STARTED = 'RESOLVE_URIS_STARTED';
|
||||||
|
export const RESOLVE_URIS_COMPLETED = 'RESOLVE_URIS_COMPLETED';
|
||||||
|
export const FETCH_CHANNEL_CLAIMS_STARTED = 'FETCH_CHANNEL_CLAIMS_STARTED';
|
||||||
|
export const FETCH_CHANNEL_CLAIMS_COMPLETED = 'FETCH_CHANNEL_CLAIMS_COMPLETED';
|
||||||
|
export const FETCH_CHANNEL_CLAIM_COUNT_STARTED = 'FETCH_CHANNEL_CLAIM_COUNT_STARTED';
|
||||||
|
export const FETCH_CHANNEL_CLAIM_COUNT_COMPLETED = 'FETCH_CHANNEL_CLAIM_COUNT_COMPLETED';
|
||||||
|
export const FETCH_CLAIM_LIST_MINE_STARTED = 'FETCH_CLAIM_LIST_MINE_STARTED';
|
||||||
|
export const FETCH_CLAIM_LIST_MINE_COMPLETED = 'FETCH_CLAIM_LIST_MINE_COMPLETED';
|
||||||
|
export const ABANDON_CLAIM_STARTED = 'ABANDON_CLAIM_STARTED';
|
||||||
|
export const ABANDON_CLAIM_SUCCEEDED = 'ABANDON_CLAIM_SUCCEEDED';
|
||||||
|
export const FETCH_CHANNEL_LIST_STARTED = 'FETCH_CHANNEL_LIST_STARTED';
|
||||||
|
export const FETCH_CHANNEL_LIST_COMPLETED = 'FETCH_CHANNEL_LIST_COMPLETED';
|
||||||
|
export const CREATE_CHANNEL_STARTED = 'CREATE_CHANNEL_STARTED';
|
||||||
|
export const CREATE_CHANNEL_COMPLETED = 'CREATE_CHANNEL_COMPLETED';
|
||||||
|
export const PUBLISH_STARTED = 'PUBLISH_STARTED';
|
||||||
|
export const PUBLISH_COMPLETED = 'PUBLISH_COMPLETED';
|
||||||
|
export const PUBLISH_FAILED = 'PUBLISH_FAILED';
|
||||||
|
export const SET_PLAYING_URI = 'SET_PLAYING_URI';
|
||||||
|
export const SET_CONTENT_POSITION = 'SET_CONTENT_POSITION';
|
||||||
|
export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED';
|
||||||
|
export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI';
|
||||||
|
export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
export const CHANNEL_SUBSCRIBE = 'CHANNEL_SUBSCRIBE';
|
||||||
|
export const CHANNEL_UNSUBSCRIBE = 'CHANNEL_UNSUBSCRIBE';
|
||||||
|
export const CHANNEL_SUBSCRIPTION_ENABLE_NOTIFICATIONS =
|
||||||
|
'CHANNEL_SUBSCRIPTION_ENABLE_NOTIFICATIONS';
|
||||||
|
export const CHANNEL_SUBSCRIPTION_DISABLE_NOTIFICATIONS =
|
||||||
|
'CHANNEL_SUBSCRIPTION_DISABLE_NOTIFICATIONS';
|
||||||
|
export const HAS_FETCHED_SUBSCRIPTIONS = 'HAS_FETCHED_SUBSCRIPTIONS';
|
||||||
|
export const SET_SUBSCRIPTION_LATEST = 'SET_SUBSCRIPTION_LATEST';
|
||||||
|
export const UPDATE_SUBSCRIPTION_UNREADS = 'UPDATE_SUBSCRIPTION_UNREADS';
|
||||||
|
export const REMOVE_SUBSCRIPTION_UNREADS = 'REMOVE_SUBSCRIPTION_UNREADS';
|
||||||
|
export const CHECK_SUBSCRIPTION_STARTED = 'CHECK_SUBSCRIPTION_STARTED';
|
||||||
|
export const CHECK_SUBSCRIPTION_COMPLETED = 'CHECK_SUBSCRIPTION_COMPLETED';
|
||||||
|
export const CHECK_SUBSCRIPTIONS_SUBSCRIBE = 'CHECK_SUBSCRIPTIONS_SUBSCRIBE';
|
||||||
|
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';
|
||||||
|
|
5
src/constants/claim.js
Normal file
5
src/constants/claim.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export const MINIMUM_PUBLISH_BID = 0.00000001;
|
||||||
|
|
||||||
|
export const CHANNEL_ANONYMOUS = 'anonymous';
|
||||||
|
export const CHANNEL_NEW = 'new';
|
||||||
|
export const PAGE_SIZE = 20;
|
12
src/constants/subscriptions.js
Normal file
12
src/constants/subscriptions.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export const VIEW_ALL = 'view_all';
|
||||||
|
export const VIEW_LATEST_FIRST = 'view_latest_first';
|
||||||
|
|
||||||
|
// Types for unreads
|
||||||
|
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';
|
39
src/index.js
39
src/index.js
|
@ -1,6 +1,7 @@
|
||||||
import * as LBRYINC_ACTIONS from 'constants/action_types';
|
import * as LBRYINC_ACTIONS from 'constants/action_types';
|
||||||
import Lbryio from 'lbryio';
|
import Lbryio from 'lbryio';
|
||||||
import rewards from 'rewards';
|
import rewards from 'rewards';
|
||||||
|
import subscriptionsReducer from 'redux/reducers/subscriptions';
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
export { LBRYINC_ACTIONS };
|
export { LBRYINC_ACTIONS };
|
||||||
|
@ -17,6 +18,24 @@ export {
|
||||||
doClaimRewardClearError,
|
doClaimRewardClearError,
|
||||||
doFetchRewardedContent,
|
doFetchRewardedContent,
|
||||||
} from 'redux/actions/rewards';
|
} from 'redux/actions/rewards';
|
||||||
|
export {
|
||||||
|
doChannelSubscribe,
|
||||||
|
doChannelUnsubscribe,
|
||||||
|
doChannelSubscriptionEnableNotifications,
|
||||||
|
doChannelSubscriptionDisableNotifications,
|
||||||
|
doCheckSubscription,
|
||||||
|
doCheckSubscriptions,
|
||||||
|
doCheckSubscriptionsInit,
|
||||||
|
doCompleteFirstRun,
|
||||||
|
doFetchMySubscriptions,
|
||||||
|
doFetchRecommendedSubscriptions,
|
||||||
|
doRemoveUnreadSubscription,
|
||||||
|
doRemoveUnreadSubscriptions,
|
||||||
|
doSetViewMode,
|
||||||
|
doShowSuggestedSubs,
|
||||||
|
doUpdateUnreadSubscriptions,
|
||||||
|
setSubscriptionLatest,
|
||||||
|
} from 'redux/actions/subscriptions';
|
||||||
export {
|
export {
|
||||||
doFetchInviteStatus,
|
doFetchInviteStatus,
|
||||||
doInstallNew,
|
doInstallNew,
|
||||||
|
@ -40,6 +59,7 @@ export {
|
||||||
// reducers
|
// reducers
|
||||||
export { authReducer } from 'redux/reducers/auth';
|
export { authReducer } from 'redux/reducers/auth';
|
||||||
export { rewardsReducer } from 'redux/reducers/rewards';
|
export { rewardsReducer } from 'redux/reducers/rewards';
|
||||||
|
export { subscriptionsReducer };
|
||||||
export { userReducer } from 'redux/reducers/user';
|
export { userReducer } from 'redux/reducers/user';
|
||||||
|
|
||||||
// selectors
|
// selectors
|
||||||
|
@ -64,6 +84,25 @@ export {
|
||||||
selectRewardContentClaimIds,
|
selectRewardContentClaimIds,
|
||||||
selectReferralReward,
|
selectReferralReward,
|
||||||
} from 'redux/selectors/rewards';
|
} from 'redux/selectors/rewards';
|
||||||
|
export {
|
||||||
|
makeSelectIsNew,
|
||||||
|
makeSelectIsSubscribed,
|
||||||
|
makeSelectUnreadByChannel,
|
||||||
|
selectEnabledChannelNotifications,
|
||||||
|
selectSubscriptions,
|
||||||
|
selectIsFetchingSubscriptions,
|
||||||
|
selectViewMode,
|
||||||
|
selectSuggested,
|
||||||
|
selectIsFetchingSuggested,
|
||||||
|
selectSuggestedChannels,
|
||||||
|
selectFirstRunCompleted,
|
||||||
|
selectShowSuggestedSubs,
|
||||||
|
selectSubscriptionsBeingFetched,
|
||||||
|
selectUnreadByChannel,
|
||||||
|
selectUnreadAmount,
|
||||||
|
selectUnreadSubscriptions,
|
||||||
|
selectSubscriptionClaims,
|
||||||
|
} from 'redux/selectors/subscriptions';
|
||||||
export {
|
export {
|
||||||
selectAuthenticationIsPending,
|
selectAuthenticationIsPending,
|
||||||
selectUserIsPending,
|
selectUserIsPending,
|
||||||
|
|
466
src/redux/actions/subscriptions.js
Normal file
466
src/redux/actions/subscriptions.js
Normal file
|
@ -0,0 +1,466 @@
|
||||||
|
// @flow
|
||||||
|
import type { GetState } from 'types/redux';
|
||||||
|
import type {
|
||||||
|
Dispatch as ReduxDispatch,
|
||||||
|
SubscriptionState,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionNotificationType,
|
||||||
|
ViewMode,
|
||||||
|
UnreadSubscription,
|
||||||
|
} from 'types/subscription';
|
||||||
|
import { PAGE_SIZE } from 'constants/claim';
|
||||||
|
import { doClaimRewardType } from 'redux/actions/rewards';
|
||||||
|
import { selectSubscriptions, selectUnreadByChannel } from 'redux/selectors/subscriptions';
|
||||||
|
import { Lbry, buildURI, parseURI, doResolveUris, doPurchaseUri } from 'lbry-redux';
|
||||||
|
import * as ACTIONS from 'constants/action_types';
|
||||||
|
import * as NOTIFICATION_TYPES from 'constants/subscriptions';
|
||||||
|
import Lbryio from 'lbryio';
|
||||||
|
import rewards from 'rewards';
|
||||||
|
import Promise from 'bluebird';
|
||||||
|
// import * as SETTINGS from 'constants/settings';
|
||||||
|
// import { makeSelectClientSetting } from 'redux/selectors/settings';
|
||||||
|
|
||||||
|
const CHECK_SUBSCRIPTIONS_INTERVAL = 15 * 60 * 1000;
|
||||||
|
const SUBSCRIPTION_DOWNLOAD_LIMIT = 1;
|
||||||
|
|
||||||
|
export const doSetViewMode = (viewMode: ViewMode) => (dispatch: ReduxDispatch) =>
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.SET_VIEW_MODE,
|
||||||
|
data: viewMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setSubscriptionLatest = (subscription: Subscription, uri: string) => (
|
||||||
|
dispatch: ReduxDispatch
|
||||||
|
) =>
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.SET_SUBSCRIPTION_LATEST,
|
||||||
|
data: {
|
||||||
|
subscription,
|
||||||
|
uri,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate a channels unread subscriptions or update the type
|
||||||
|
export const doUpdateUnreadSubscriptions = (
|
||||||
|
channelUri: string,
|
||||||
|
uris: ?Array<string>,
|
||||||
|
type: ?SubscriptionNotificationType
|
||||||
|
) => (dispatch: ReduxDispatch, getState: GetState) => {
|
||||||
|
const state = getState();
|
||||||
|
const unreadByChannel = selectUnreadByChannel(state);
|
||||||
|
const currentUnreadForChannel: UnreadSubscription = unreadByChannel[channelUri];
|
||||||
|
|
||||||
|
let newUris: Array = [];
|
||||||
|
let newType: string = null;
|
||||||
|
|
||||||
|
if (!currentUnreadForChannel) {
|
||||||
|
newUris = uris;
|
||||||
|
newType = type;
|
||||||
|
} else {
|
||||||
|
if (uris) {
|
||||||
|
// If a channel currently has no unread uris, just add them all
|
||||||
|
if (!currentUnreadForChannel.uris || !currentUnreadForChannel.uris.length) {
|
||||||
|
newUris = uris;
|
||||||
|
} else {
|
||||||
|
// They already have unreads and now there are new ones
|
||||||
|
// Add the new ones to the beginning of the list
|
||||||
|
// Make sure there are no duplicates
|
||||||
|
const currentUnreadUris = currentUnreadForChannel.uris;
|
||||||
|
newUris = uris.filter(uri => !currentUnreadUris.includes(uri)).concat(currentUnreadUris);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newUris = currentUnreadForChannel.uris;
|
||||||
|
}
|
||||||
|
|
||||||
|
newType = type || currentUnreadForChannel.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.UPDATE_SUBSCRIPTION_UNREADS,
|
||||||
|
data: {
|
||||||
|
channel: channelUri,
|
||||||
|
uris: newUris,
|
||||||
|
type: newType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove multiple files (or all) from a channels unread subscriptions
|
||||||
|
export const doRemoveUnreadSubscriptions = (channelUri: ?string, readUris: ?Array<string>) => (
|
||||||
|
dispatch: ReduxDispatch,
|
||||||
|
getState: GetState
|
||||||
|
) => {
|
||||||
|
const state = getState();
|
||||||
|
const unreadByChannel = selectUnreadByChannel(state);
|
||||||
|
|
||||||
|
// If no channel is passed in, remove all unread subscriptions from all channels
|
||||||
|
if (!channelUri) {
|
||||||
|
return dispatch({
|
||||||
|
type: ACTIONS.REMOVE_SUBSCRIPTION_UNREADS,
|
||||||
|
data: { channel: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentChannelUnread = unreadByChannel[channelUri];
|
||||||
|
if (!currentChannelUnread || !currentChannelUnread.uris) {
|
||||||
|
// Channel passed in doesn't have any unreads
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each uri passed in, remove it from the list of unread uris
|
||||||
|
// If no uris are passed in, remove them all
|
||||||
|
let newUris;
|
||||||
|
if (readUris) {
|
||||||
|
const urisToRemoveMap = readUris.reduce(
|
||||||
|
(acc, val) => ({
|
||||||
|
...acc,
|
||||||
|
[val]: true,
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredUris = currentChannelUnread.uris.filter(uri => !urisToRemoveMap[uri]);
|
||||||
|
newUris = filteredUris.length ? filteredUris : null;
|
||||||
|
} else {
|
||||||
|
newUris = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatch({
|
||||||
|
type: ACTIONS.REMOVE_SUBSCRIPTION_UNREADS,
|
||||||
|
data: {
|
||||||
|
channel: channelUri,
|
||||||
|
uris: newUris,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove a single file from a channels unread subscriptions
|
||||||
|
export const doRemoveUnreadSubscription = (channelUri: string, readUri: string) => (
|
||||||
|
dispatch: ReduxDispatch
|
||||||
|
) => {
|
||||||
|
dispatch(doRemoveUnreadSubscriptions(channelUri, [readUri]));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCheckSubscription = (subscriptionUri: string, shouldNotify?: boolean) => (
|
||||||
|
dispatch: ReduxDispatch,
|
||||||
|
getState: GetState
|
||||||
|
) => {
|
||||||
|
// no dispatching FETCH_CHANNEL_CLAIMS_STARTED; causes loading issues on <SubscriptionsPage>
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const shouldAutoDownload = false; // makeSelectClientSetting(SETTINGS.AUTO_DOWNLOAD)(state);
|
||||||
|
const savedSubscription = state.subscriptions.subscriptions.find(
|
||||||
|
sub => sub.uri === subscriptionUri
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!savedSubscription) {
|
||||||
|
throw Error(
|
||||||
|
`Trying to find new content for ${subscriptionUri} but it doesn't exist in your subscriptions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We may be duplicating calls here. Can this logic be baked into doFetchClaimsByChannel?
|
||||||
|
Lbry.claim_list_by_channel({ uri: subscriptionUri, page: 1, page_size: PAGE_SIZE }).then(
|
||||||
|
claimListByChannel => {
|
||||||
|
const claimResult = claimListByChannel[subscriptionUri] || {};
|
||||||
|
const { claims_in_channel: claimsInChannel } = claimResult;
|
||||||
|
|
||||||
|
// may happen if subscribed to an abandoned channel or an empty channel
|
||||||
|
if (!claimsInChannel || !claimsInChannel.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if the latest subscription currently saved is actually the latest subscription
|
||||||
|
const latestIndex = claimsInChannel.findIndex(
|
||||||
|
claim => `${claim.name}#${claim.claim_id}` === savedSubscription.latest
|
||||||
|
);
|
||||||
|
|
||||||
|
// If latest is -1, it is a newly subscribed channel or there have been 10+ claims published since last viewed
|
||||||
|
const latestIndexToNotify = latestIndex === -1 ? 10 : latestIndex;
|
||||||
|
|
||||||
|
// If latest is 0, nothing has changed
|
||||||
|
// Do not download/notify about new content, it would download/notify 10 claims per channel
|
||||||
|
if (latestIndex !== 0 && savedSubscription.latest) {
|
||||||
|
let downloadCount = 0;
|
||||||
|
|
||||||
|
const newUnread = [];
|
||||||
|
claimsInChannel.slice(0, latestIndexToNotify).forEach(claim => {
|
||||||
|
const uri = buildURI({ contentName: claim.name, claimId: claim.claim_id }, true);
|
||||||
|
const shouldDownload =
|
||||||
|
shouldAutoDownload &&
|
||||||
|
Boolean(
|
||||||
|
downloadCount < SUBSCRIPTION_DOWNLOAD_LIMIT && !claim.value.stream.metadata.fee
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the new content to the list of "un-read" subscriptions
|
||||||
|
if (shouldNotify) {
|
||||||
|
newUnread.push(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDownload) {
|
||||||
|
downloadCount += 1;
|
||||||
|
dispatch(doPurchaseUri(uri, { cost: 0 }, true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
doUpdateUnreadSubscriptions(
|
||||||
|
subscriptionUri,
|
||||||
|
newUnread,
|
||||||
|
downloadCount > 0 ? NOTIFICATION_TYPES.DOWNLOADING : NOTIFICATION_TYPES.NOTIFY_ONLY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the latest piece of content for a channel
|
||||||
|
// This allows the app to know if there has been new content since it was last set
|
||||||
|
dispatch(
|
||||||
|
setSubscriptionLatest(
|
||||||
|
{
|
||||||
|
channelName: claimsInChannel[0].channel_name,
|
||||||
|
uri: buildURI(
|
||||||
|
{
|
||||||
|
channelName: claimsInChannel[0].channel_name,
|
||||||
|
claimId: claimsInChannel[0].claim_id,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
),
|
||||||
|
},
|
||||||
|
buildURI(
|
||||||
|
{ contentName: claimsInChannel[0].name, claimId: claimsInChannel[0].claim_id },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// calling FETCH_CHANNEL_CLAIMS_COMPLETED after not calling STARTED
|
||||||
|
// means it will delete a non-existant fetchingChannelClaims[uri]
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.FETCH_CHANNEL_CLAIMS_COMPLETED,
|
||||||
|
data: {
|
||||||
|
uri: subscriptionUri,
|
||||||
|
claims: claimsInChannel || [],
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doChannelSubscribe = (subscription: Subscription) => (
|
||||||
|
dispatch: ReduxDispatch,
|
||||||
|
getState: GetState
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
settings: { daemonSettings },
|
||||||
|
} = getState();
|
||||||
|
|
||||||
|
const isSharingData = daemonSettings ? daemonSettings.share_usage_data : true;
|
||||||
|
|
||||||
|
const subscriptionUri = subscription.uri;
|
||||||
|
if (!subscriptionUri.startsWith('lbry://')) {
|
||||||
|
throw Error(
|
||||||
|
`Subscription uris must inclue the "lbry://" prefix.\nTried to subscribe to ${subscriptionUri}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIBE,
|
||||||
|
data: subscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
// if the user isn't sharing data, keep the subscriptions entirely in the app
|
||||||
|
if (isSharingData) {
|
||||||
|
const { claimId } = parseURI(subscription.uri);
|
||||||
|
// They are sharing data, we can store their subscriptions in our internal database
|
||||||
|
Lbryio.call('subscription', 'new', {
|
||||||
|
channel_name: subscription.channelName,
|
||||||
|
claim_id: claimId,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(doClaimRewardType(rewards.TYPE_SUBSCRIPTION, { failSilently: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(doCheckSubscription(subscription.uri, true));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doChannelUnsubscribe = (subscription: Subscription) => (
|
||||||
|
dispatch: ReduxDispatch,
|
||||||
|
getState: GetState
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
settings: { daemonSettings },
|
||||||
|
} = getState();
|
||||||
|
const isSharingData = daemonSettings ? daemonSettings.share_usage_data : true;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.CHANNEL_UNSUBSCRIBE,
|
||||||
|
data: subscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSharingData) {
|
||||||
|
const { claimId } = parseURI(subscription.uri);
|
||||||
|
Lbryio.call('subscription', 'delete', {
|
||||||
|
claim_id: claimId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCheckSubscriptions = () => (dispatch: ReduxDispatch, getState: GetState) => {
|
||||||
|
const state = getState();
|
||||||
|
const subscriptions = selectSubscriptions(state);
|
||||||
|
|
||||||
|
subscriptions.forEach((sub: Subscription) => {
|
||||||
|
dispatch(doCheckSubscription(sub.uri, true));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doFetchMySubscriptions = () => (dispatch: ReduxDispatch, getState: GetState) => {
|
||||||
|
const state: { subscriptions: SubscriptionState, settings: any } = getState();
|
||||||
|
const { subscriptions: reduxSubscriptions } = state.subscriptions;
|
||||||
|
|
||||||
|
// default to true if daemonSettings not found
|
||||||
|
const isSharingData =
|
||||||
|
state.settings && state.settings.daemonSettings
|
||||||
|
? state.settings.daemonSettings.share_usage_data
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (!isSharingData && isSharingData !== undefined) {
|
||||||
|
// They aren't sharing their data, subscriptions will be handled by persisted redux state
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// most of this logic comes from scenarios where the db isn't synced with redux
|
||||||
|
// this will happen if the user stops sharing data
|
||||||
|
dispatch({ type: ACTIONS.FETCH_SUBSCRIPTIONS_START });
|
||||||
|
|
||||||
|
Lbryio.call('subscription', 'list')
|
||||||
|
.then(dbSubscriptions => {
|
||||||
|
const storedSubscriptions = dbSubscriptions || [];
|
||||||
|
|
||||||
|
// User has no subscriptions in db or redux
|
||||||
|
if (!storedSubscriptions.length && (!reduxSubscriptions || !reduxSubscriptions.length)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is some mismatch between redux state and db state
|
||||||
|
// If something is in the db, but not in redux, add it to redux
|
||||||
|
// If something is in redux, but not in the db, add it to the db
|
||||||
|
if (storedSubscriptions.length !== reduxSubscriptions.length) {
|
||||||
|
const dbSubMap = {};
|
||||||
|
const reduxSubMap = {};
|
||||||
|
const subsNotInDB = [];
|
||||||
|
const subscriptionsToReturn = reduxSubscriptions.slice();
|
||||||
|
|
||||||
|
storedSubscriptions.forEach(sub => {
|
||||||
|
dbSubMap[sub.claim_id] = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
reduxSubscriptions.forEach(sub => {
|
||||||
|
const { claimId } = parseURI(sub.uri);
|
||||||
|
reduxSubMap[claimId] = 1;
|
||||||
|
|
||||||
|
if (!dbSubMap[claimId]) {
|
||||||
|
subsNotInDB.push({
|
||||||
|
claim_id: claimId,
|
||||||
|
channel_name: sub.channelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
storedSubscriptions.forEach(sub => {
|
||||||
|
if (!reduxSubMap[sub.claim_id]) {
|
||||||
|
const uri = `lbry://${sub.channel_name}#${sub.claim_id}`;
|
||||||
|
subscriptionsToReturn.push({ uri, channelName: sub.channel_name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(subsNotInDB.map(payload => Lbryio.call('subscription', 'new', payload)))
|
||||||
|
.then(() => subscriptionsToReturn)
|
||||||
|
.catch(
|
||||||
|
() =>
|
||||||
|
// let it fail, we will try again when the navigate to the subscriptions page
|
||||||
|
subscriptionsToReturn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB is already synced, just return the subscriptions in redux
|
||||||
|
return reduxSubscriptions;
|
||||||
|
})
|
||||||
|
.then((subscriptions: Array<Subscription>) => {
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.FETCH_SUBSCRIPTIONS_SUCCESS,
|
||||||
|
data: subscriptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(doResolveUris(subscriptions.map(({ uri }) => uri)));
|
||||||
|
dispatch(doCheckSubscriptions());
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.FETCH_SUBSCRIPTIONS_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doCheckSubscriptionsInit = () => (dispatch: ReduxDispatch) => {
|
||||||
|
// 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);
|
||||||
|
const checkSubscriptionsTimer = setInterval(
|
||||||
|
() => dispatch(doCheckSubscriptions()),
|
||||||
|
CHECK_SUBSCRIPTIONS_INTERVAL
|
||||||
|
);
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.CHECK_SUBSCRIPTIONS_SUBSCRIBE,
|
||||||
|
data: { checkSubscriptionsTimer },
|
||||||
|
});
|
||||||
|
setInterval(() => dispatch(doCheckSubscriptions()), CHECK_SUBSCRIPTIONS_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doChannelSubscriptionEnableNotifications = (channelName: string) => (
|
||||||
|
dispatch: ReduxDispatch
|
||||||
|
) =>
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIPTION_ENABLE_NOTIFICATIONS,
|
||||||
|
data: channelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doChannelSubscriptionDisableNotifications = (channelName: string) => (
|
||||||
|
dispatch: ReduxDispatch
|
||||||
|
) =>
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIPTION_DISABLE_NOTIFICATIONS,
|
||||||
|
data: channelName,
|
||||||
|
});
|
213
src/redux/reducers/subscriptions.js
Normal file
213
src/redux/reducers/subscriptions.js
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
// @flow
|
||||||
|
import * as ACTIONS from 'constants/action_types';
|
||||||
|
import { VIEW_ALL } from 'constants/subscriptions';
|
||||||
|
import { handleActions } from 'util/redux-utils';
|
||||||
|
import type {
|
||||||
|
SubscriptionState,
|
||||||
|
Subscription,
|
||||||
|
DoChannelSubscribe,
|
||||||
|
DoChannelUnsubscribe,
|
||||||
|
DoChannelSubscriptionEnableNotifications,
|
||||||
|
DoChannelSubscriptionDisableNotifications,
|
||||||
|
SetSubscriptionLatest,
|
||||||
|
DoUpdateSubscriptionUnreads,
|
||||||
|
DoRemoveSubscriptionUnreads,
|
||||||
|
FetchedSubscriptionsSucess,
|
||||||
|
SetViewMode,
|
||||||
|
GetSuggestedSubscriptionsSuccess,
|
||||||
|
} from 'types/subscription';
|
||||||
|
|
||||||
|
const defaultState: SubscriptionState = {
|
||||||
|
enabledChannelNotifications: [],
|
||||||
|
subscriptions: [],
|
||||||
|
unread: {},
|
||||||
|
suggested: {},
|
||||||
|
loading: false,
|
||||||
|
viewMode: VIEW_ALL,
|
||||||
|
loadingSuggested: false,
|
||||||
|
firstRunCompleted: false,
|
||||||
|
showSuggestedSubs: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions(
|
||||||
|
{
|
||||||
|
[ACTIONS.CHANNEL_SUBSCRIBE]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoChannelSubscribe
|
||||||
|
): SubscriptionState => {
|
||||||
|
const newSubscription: Subscription = action.data;
|
||||||
|
const newSubscriptions: Array<Subscription> = state.subscriptions.slice();
|
||||||
|
newSubscriptions.unshift(newSubscription);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subscriptions: newSubscriptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.CHANNEL_UNSUBSCRIBE]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoChannelUnsubscribe
|
||||||
|
): SubscriptionState => {
|
||||||
|
const subscriptionToRemove: Subscription = action.data;
|
||||||
|
const newSubscriptions = state.subscriptions
|
||||||
|
.slice()
|
||||||
|
.filter(subscription => subscription.channelName !== subscriptionToRemove.channelName);
|
||||||
|
|
||||||
|
// Check if we need to remove it from the 'unread' state
|
||||||
|
const { unread } = state;
|
||||||
|
if (unread[subscriptionToRemove.uri]) {
|
||||||
|
delete unread[subscriptionToRemove.uri];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unread: { ...unread },
|
||||||
|
subscriptions: newSubscriptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.SET_SUBSCRIPTION_LATEST]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: SetSubscriptionLatest
|
||||||
|
): SubscriptionState => ({
|
||||||
|
...state,
|
||||||
|
subscriptions: state.subscriptions.map(
|
||||||
|
subscription =>
|
||||||
|
subscription.channelName === action.data.subscription.channelName
|
||||||
|
? { ...subscription, latest: action.data.uri }
|
||||||
|
: subscription
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[ACTIONS.UPDATE_SUBSCRIPTION_UNREADS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoUpdateSubscriptionUnreads
|
||||||
|
): SubscriptionState => {
|
||||||
|
const { channel, uris, type } = action.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unread: {
|
||||||
|
...state.unread,
|
||||||
|
[channel]: {
|
||||||
|
uris,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.REMOVE_SUBSCRIPTION_UNREADS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoRemoveSubscriptionUnreads
|
||||||
|
): SubscriptionState => {
|
||||||
|
const { channel, uris } = action.data;
|
||||||
|
|
||||||
|
// If no channel is passed in, remove all unreads
|
||||||
|
let newUnread;
|
||||||
|
if (channel) {
|
||||||
|
newUnread = { ...state.unread };
|
||||||
|
|
||||||
|
if (!uris) {
|
||||||
|
delete newUnread[channel];
|
||||||
|
} else {
|
||||||
|
newUnread[channel].uris = uris;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newUnread = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unread: {
|
||||||
|
...newUnread,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.CHANNEL_SUBSCRIPTION_ENABLE_NOTIFICATIONS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoChannelSubscriptionEnableNotifications
|
||||||
|
): SubscriptionState => {
|
||||||
|
const channelName = action.data;
|
||||||
|
|
||||||
|
const newEnabledChannelNotifications: Array<
|
||||||
|
string
|
||||||
|
> = state.enabledChannelNotifications.slice();
|
||||||
|
if (
|
||||||
|
channelName &&
|
||||||
|
channelName.trim().length > 0 &&
|
||||||
|
newEnabledChannelNotifications.indexOf(channelName) === -1
|
||||||
|
) {
|
||||||
|
newEnabledChannelNotifications.push(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
enabledChannelNotifications: newEnabledChannelNotifications,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.CHANNEL_SUBSCRIPTION_DISABLE_NOTIFICATIONS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: DoChannelSubscriptionDisableNotifications
|
||||||
|
): SubscriptionState => {
|
||||||
|
const channelName = action.data;
|
||||||
|
|
||||||
|
const newEnabledChannelNotifications: Array<
|
||||||
|
string
|
||||||
|
> = state.enabledChannelNotifications.slice();
|
||||||
|
const index = newEnabledChannelNotifications.indexOf(channelName);
|
||||||
|
if (index > -1) {
|
||||||
|
newEnabledChannelNotifications.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
enabledChannelNotifications: newEnabledChannelNotifications,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[ACTIONS.FETCH_SUBSCRIPTIONS_START]: (state: SubscriptionState): SubscriptionState => ({
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
}),
|
||||||
|
[ACTIONS.FETCH_SUBSCRIPTIONS_FAIL]: (state: SubscriptionState): SubscriptionState => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
[ACTIONS.FETCH_SUBSCRIPTIONS_SUCCESS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: FetchedSubscriptionsSucess
|
||||||
|
): SubscriptionState => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
subscriptions: action.data,
|
||||||
|
}),
|
||||||
|
[ACTIONS.SET_VIEW_MODE]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: SetViewMode
|
||||||
|
): SubscriptionState => ({
|
||||||
|
...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
|
||||||
|
);
|
283
src/redux/selectors/subscriptions.js
Normal file
283
src/redux/selectors/subscriptions.js
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
import { SUGGESTED_FEATURED, SUGGESTED_TOP_SUBSCRIBED } from 'constants/subscriptions';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import {
|
||||||
|
selectAllClaimsByChannel,
|
||||||
|
selectClaimsById,
|
||||||
|
selectAllFetchingChannelClaims,
|
||||||
|
makeSelectChannelForClaimUri,
|
||||||
|
selectClaimsByUri,
|
||||||
|
parseURI,
|
||||||
|
} from 'lbry-redux';
|
||||||
|
import { swapKeyAndValue } from 'util/swap-json';
|
||||||
|
|
||||||
|
// Returns the entire subscriptions state
|
||||||
|
const selectState = state => state.subscriptions || {};
|
||||||
|
|
||||||
|
// Returns the list of channel uris a user is subscribed to
|
||||||
|
export const selectSubscriptions = createSelector(selectState, state => state.subscriptions);
|
||||||
|
|
||||||
|
// Fetching list of users subscriptions
|
||||||
|
export const selectIsFetchingSubscriptions = createSelector(selectState, state => state.loading);
|
||||||
|
|
||||||
|
// 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 selectIsFetchingSuggested = 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,
|
||||||
|
selectAllFetchingChannelClaims,
|
||||||
|
(subscriptions, fetchingChannelClaims) => {
|
||||||
|
const fetchingSubscriptionMap = {};
|
||||||
|
subscriptions.forEach(sub => {
|
||||||
|
const isFetching = fetchingChannelClaims && fetchingChannelClaims[sub.uri];
|
||||||
|
if (isFetching) {
|
||||||
|
fetchingSubscriptionMap[sub.uri] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchingSubscriptionMap;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectUnreadByChannel = createSelector(selectState, state => state.unread);
|
||||||
|
|
||||||
|
// Returns the current total of unread subscriptions
|
||||||
|
export const selectUnreadAmount = createSelector(selectUnreadByChannel, unreadByChannel => {
|
||||||
|
const unreadChannels = Object.keys(unreadByChannel);
|
||||||
|
let badges = 0;
|
||||||
|
|
||||||
|
if (!unreadChannels.length) {
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadChannels.forEach(channel => {
|
||||||
|
badges += unreadByChannel[channel].uris.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns the uris with channels as an array with the channel with the newest content first
|
||||||
|
// If you just want the `unread` state, use selectUnread
|
||||||
|
export const selectUnreadSubscriptions = createSelector(
|
||||||
|
selectUnreadAmount,
|
||||||
|
selectUnreadByChannel,
|
||||||
|
selectClaimsByUri,
|
||||||
|
(unreadAmount, unreadByChannel, claimsByUri) => {
|
||||||
|
// determine which channel has the newest content
|
||||||
|
const unreadList = [];
|
||||||
|
if (!unreadAmount) {
|
||||||
|
return unreadList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelUriList = Object.keys(unreadByChannel);
|
||||||
|
|
||||||
|
// There is only one channel with unread notifications
|
||||||
|
if (unreadAmount === 1) {
|
||||||
|
channelUriList.forEach(channel => {
|
||||||
|
const unreadChannel = {
|
||||||
|
channel,
|
||||||
|
uris: unreadByChannel[channel].uris,
|
||||||
|
};
|
||||||
|
unreadList.push(unreadChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unreadList;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelUriList
|
||||||
|
.sort((channel1, channel2) => {
|
||||||
|
const latestUriFromChannel1 = unreadByChannel[channel1].uris[0];
|
||||||
|
const latestClaimFromChannel1 = claimsByUri[latestUriFromChannel1] || {};
|
||||||
|
const latestUriFromChannel2 = unreadByChannel[channel2].uris[0];
|
||||||
|
const latestClaimFromChannel2 = claimsByUri[latestUriFromChannel2] || {};
|
||||||
|
|
||||||
|
const latestHeightFromChannel1 = latestClaimFromChannel1.height || 0;
|
||||||
|
const latestHeightFromChannel2 = latestClaimFromChannel2.height || 0;
|
||||||
|
|
||||||
|
if (latestHeightFromChannel1 !== latestHeightFromChannel2) {
|
||||||
|
return latestHeightFromChannel2 - latestHeightFromChannel1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.forEach(channel => {
|
||||||
|
const unreadSubscription = unreadByChannel[channel];
|
||||||
|
const unreadChannel = {
|
||||||
|
channel,
|
||||||
|
uris: unreadSubscription.uris,
|
||||||
|
};
|
||||||
|
|
||||||
|
unreadList.push(unreadChannel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unreadList;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns all unread subscriptions for a uri passed in
|
||||||
|
export const makeSelectUnreadByChannel = uri =>
|
||||||
|
createSelector(selectUnreadByChannel, unread => unread[uri]);
|
||||||
|
|
||||||
|
// Returns the first page of claims for every channel a user is subscribed to
|
||||||
|
export const selectSubscriptionClaims = createSelector(
|
||||||
|
selectAllClaimsByChannel,
|
||||||
|
selectClaimsById,
|
||||||
|
selectSubscriptions,
|
||||||
|
selectUnreadByChannel,
|
||||||
|
(channelIds, allClaims, savedSubscriptions, unreadByChannel) => {
|
||||||
|
// no claims loaded yet
|
||||||
|
if (!Object.keys(channelIds).length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchedSubscriptions = [];
|
||||||
|
|
||||||
|
savedSubscriptions.forEach(subscription => {
|
||||||
|
let channelClaims = [];
|
||||||
|
|
||||||
|
// if subscribed channel has content
|
||||||
|
if (channelIds[subscription.uri] && channelIds[subscription.uri]['1']) {
|
||||||
|
// This will need to be more robust, we will want to be able to load more than the first page
|
||||||
|
|
||||||
|
// Strip out any ids that will be shown as notifications
|
||||||
|
const pageOneChannelIds = channelIds[subscription.uri]['1'];
|
||||||
|
|
||||||
|
// we have the channel ids and the corresponding claims
|
||||||
|
// loop over the list of ids and grab the claim
|
||||||
|
pageOneChannelIds.forEach(id => {
|
||||||
|
const grabbedClaim = allClaims[id];
|
||||||
|
|
||||||
|
if (
|
||||||
|
unreadByChannel[subscription.uri] &&
|
||||||
|
unreadByChannel[subscription.uri].uris.some(uri => uri.includes(id))
|
||||||
|
) {
|
||||||
|
grabbedClaim.isNew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelClaims = channelClaims.concat([grabbedClaim]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedSubscriptions = fetchedSubscriptions.concat(channelClaims);
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchedSubscriptions;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns true if a user is subscribed to the channel associated with the uri passed in
|
||||||
|
// Accepts content or channel uris
|
||||||
|
export const makeSelectIsSubscribed = uri =>
|
||||||
|
createSelector(
|
||||||
|
selectSubscriptions,
|
||||||
|
makeSelectChannelForClaimUri(uri, true),
|
||||||
|
(subscriptions, channelUri) => {
|
||||||
|
if (channelUri) {
|
||||||
|
return subscriptions.some(sub => sub.uri === channelUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't get a channel uri from the claim uri, the uri passed in might be a channel already
|
||||||
|
const { isChannel } = parseURI(uri);
|
||||||
|
if (isChannel) {
|
||||||
|
const uriWithPrefix = uri.startsWith('lbry://') ? uri : `lbry://${uri}`;
|
||||||
|
return subscriptions.some(sub => sub.uri === uriWithPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const makeSelectIsNew = uri =>
|
||||||
|
createSelector(
|
||||||
|
makeSelectIsSubscribed(uri),
|
||||||
|
makeSelectChannelForClaimUri(uri),
|
||||||
|
selectUnreadByChannel,
|
||||||
|
(isSubscribed, channel, unreadByChannel) => {
|
||||||
|
if (!isSubscribed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadForChannel = unreadByChannel[`lbry://${channel}`];
|
||||||
|
if (unreadForChannel) {
|
||||||
|
return unreadForChannel.uris.includes(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
// If they are subscribed, check to see if this uri is in the list of unreads
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectEnabledChannelNotifications = createSelector(
|
||||||
|
selectState,
|
||||||
|
state => state.enabledChannelNotifications
|
||||||
|
);
|
6
src/types/redux.js
Normal file
6
src/types/redux.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-use-before-define
|
||||||
|
export type Dispatch<T> = (action: T | Promise<T> | Array<T> | ThunkAction<T>) => any; // Need to refer to ThunkAction
|
||||||
|
export type GetState = () => any;
|
||||||
|
export type ThunkAction<T> = (dispatch: Dispatch<T>, getState: GetState) => any;
|
137
src/types/subscription.js
Normal file
137
src/types/subscription.js
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
// @flow
|
||||||
|
import type { Dispatch as ReduxDispatch } from 'types/redux';
|
||||||
|
import * as ACTIONS from 'constants/action_types';
|
||||||
|
import {
|
||||||
|
DOWNLOADED,
|
||||||
|
DOWNLOADING,
|
||||||
|
NOTIFY_ONLY,
|
||||||
|
VIEW_ALL,
|
||||||
|
VIEW_LATEST_FIRST,
|
||||||
|
SUGGESTED_TOP_BID,
|
||||||
|
SUGGESTED_TOP_SUBSCRIBED,
|
||||||
|
SUGGESTED_FEATURED,
|
||||||
|
} from 'constants/subscriptions';
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
channelName: string, // @CryptoCandor,
|
||||||
|
uri: string, // lbry://@CryptoCandor#9152f3b054f692076a6882d1b58a30e8781cc8e6
|
||||||
|
latest?: string, // substratum#b0ab143243020e7831fd070d9f871e1fda948620
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tracking for new content
|
||||||
|
// i.e. If a subscription has a DOWNLOADING type, we will trigger an OS notification
|
||||||
|
// to tell users there is new content from their subscriptions
|
||||||
|
export type SubscriptionNotificationType = DOWNLOADED | DOWNLOADING | NOTIFY_ONLY;
|
||||||
|
|
||||||
|
export type UnreadSubscription = {
|
||||||
|
type: SubscriptionNotificationType,
|
||||||
|
uris: Array<string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnreadSubscriptions = {
|
||||||
|
[string]: UnreadSubscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
enabledChannelNotifications: Array<string>,
|
||||||
|
subscriptions: Array<Subscription>,
|
||||||
|
unread: UnreadSubscriptions,
|
||||||
|
loading: boolean,
|
||||||
|
viewMode: ViewMode,
|
||||||
|
suggested: SuggestedSubscriptions,
|
||||||
|
loadingSuggested: boolean,
|
||||||
|
firstRunCompleted: boolean,
|
||||||
|
showSuggestedSubs: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action types
|
||||||
|
//
|
||||||
|
export type DoChannelSubscriptionEnableNotifications = {
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIPTION_ENABLE_NOTIFICATIONS,
|
||||||
|
data: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoChannelSubscriptionDisableNotifications = {
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIPTION_DISABLE_NOTIFICATIONS,
|
||||||
|
data: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoChannelSubscribe = {
|
||||||
|
type: ACTIONS.CHANNEL_SUBSCRIBE,
|
||||||
|
data: Subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoChannelUnsubscribe = {
|
||||||
|
type: ACTIONS.CHANNEL_UNSUBSCRIBE,
|
||||||
|
data: Subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoUpdateSubscriptionUnreads = {
|
||||||
|
type: ACTIONS.UPDATE_SUBSCRIPTION_UNREADS,
|
||||||
|
data: {
|
||||||
|
channel: string,
|
||||||
|
uris: Array<string>,
|
||||||
|
type?: SubscriptionNotificationType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoRemoveSubscriptionUnreads = {
|
||||||
|
type: ACTIONS.REMOVE_SUBSCRIPTION_UNREADS,
|
||||||
|
data: {
|
||||||
|
channel: string,
|
||||||
|
uris: Array<string>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetSubscriptionLatest = {
|
||||||
|
type: ACTIONS.SET_SUBSCRIPTION_LATEST,
|
||||||
|
data: {
|
||||||
|
subscription: Subscription,
|
||||||
|
uri: string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckSubscriptionStarted = {
|
||||||
|
type: ACTIONS.CHECK_SUBSCRIPTION_STARTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckSubscriptionCompleted = {
|
||||||
|
type: ACTIONS.CHECK_SUBSCRIPTION_COMPLETED,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchedSubscriptionsSucess = {
|
||||||
|
type: ACTIONS.FETCH_SUBSCRIPTIONS_SUCCESS,
|
||||||
|
data: Array<Subscription>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetViewMode = {
|
||||||
|
type: ACTIONS.SET_VIEW_MODE,
|
||||||
|
data: ViewMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSuggestedSubscriptionsSuccess = {
|
||||||
|
type: ACTIONS.GET_SUGGESTED_SUBSCRIPTIONS_START,
|
||||||
|
data: SuggestedSubscriptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| DoChannelSubscribe
|
||||||
|
| DoChannelUnsubscribe
|
||||||
|
| DoUpdateSubscriptionUnreads
|
||||||
|
| DoRemoveSubscriptionUnreads
|
||||||
|
| SetSubscriptionLatest
|
||||||
|
| CheckSubscriptionStarted
|
||||||
|
| CheckSubscriptionCompleted
|
||||||
|
| SetViewMode
|
||||||
|
| Function;
|
||||||
|
|
||||||
|
export type Dispatch = ReduxDispatch<Action>;
|
17
src/util/redux-utils.js
Normal file
17
src/util/redux-utils.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// util for creating reducers
|
||||||
|
// based off of redux-actions
|
||||||
|
// https://redux-actions.js.org/docs/api/handleAction.html#handleactions
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const handleActions = (actionMap, defaultState) => (state = defaultState, action) => {
|
||||||
|
const handler = actionMap[action.type];
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const newState = handler(state, action);
|
||||||
|
return Object.assign({}, state, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// just return the original state if no handler
|
||||||
|
// returning a copy here breaks redux-persist
|
||||||
|
return state;
|
||||||
|
};
|
10
src/util/swap-json.js
Normal file
10
src/util/swap-json.js
Normal 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;
|
||||||
|
}
|
Loading…
Reference in a new issue