diff --git a/dist/flow-typed/Claim.js b/dist/flow-typed/Claim.js index 655a4bc..e03eeae 100644 --- a/dist/flow-typed/Claim.js +++ b/dist/flow-typed/Claim.js @@ -1,11 +1,15 @@ // @flow -declare type Claim = StreamClaim | ChannelClaim; +declare type Claim = StreamClaim | ChannelClaim | CollectionClaim; declare type ChannelClaim = GenericClaim & { value: ChannelMetadata, }; +declare type CollectionClaim = GenericClaim & { + value: CollectionMetadata, +}; + declare type StreamClaim = GenericClaim & { value: StreamMetadata, }; @@ -30,7 +34,7 @@ declare type GenericClaim = { short_url: string, // permanent_url with short id, no channel txid: string, // unique tx id type: 'claim' | 'update' | 'support', - value_type: 'stream' | 'channel', + value_type: 'stream' | 'channel' | 'collection', signing_channel?: ChannelClaim, reposted_claim?: GenericClaim, repost_channel_url?: string, @@ -74,6 +78,10 @@ declare type ChannelMetadata = GenericMetadata & { featured?: Array, }; +declare type CollectionMetadata = GenericMetadata & { + claims: Array, +} + declare type StreamMetadata = GenericMetadata & { license?: string, // License "title" ex: Creative Commons, Custom copyright license_url?: string, // Link to full license @@ -136,3 +144,71 @@ declare type PurchaseReceipt = { txid: string, type: 'purchase', }; + +declare type ClaimActionResolveInfo = { + [string]: { + stream: ?StreamClaim, + channel: ?ChannelClaim, + claimsInChannel: ?number, + collection: ?CollectionClaim, + }, +} + +declare type ChannelUpdateParams = { + claim_id: string, + bid?: string, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + replace?: boolean, + languages?: Array, + locations?: Array, + blocking?: boolean, +} + +declare type ChannelPublishParams = { + name: string, + bid: string, + blocking?: true, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + languages?: Array, +} + +declare type CollectionUpdateParams = { + claim_id: string, + claim_ids?: Array, + bid?: string, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + replace?: boolean, + languages?: Array, + locations?: Array, + blocking?: boolean, +} + +declare type CollectionPublishParams = { + name: string, + bid: string, + claim_ids: Array, + blocking?: true, + title?: string, + thumbnail_url?: string, + description?: string, + tags?: Array, + languages?: Array, +} diff --git a/dist/flow-typed/Collections.js b/dist/flow-typed/Collections.js new file mode 100644 index 0000000..83c33be --- /dev/null +++ b/dist/flow-typed/Collections.js @@ -0,0 +1,40 @@ +declare type CollectionUpdateParams = { + remove?: boolean, + claims?: Array, + name?: string, + order?: { from: number, to: number }, +} + +declare type Collection = { + id: string, + items: Array, + name: string, + type: string, + updatedAt: number, + totalItems?: number, + sourceid?: string, // if copied, claimId of original collection +}; + +declare type CollectionState = { + unpublished: CollectionGroup, + resolved: CollectionGroup, + pending: CollectionGroup, + edited: CollectionGroup, + builtin: CollectionGroup, + saved: Array, + isResolvingCollectionById: { [string]: boolean }, + error?: string | null, +}; + +declare type CollectionGroup = { + [string]: Collection, +} + +declare type CollectionEditParams = { + claims?: Array, + remove?: boolean, + claimIds?: Array, + replace?: boolean, + order?: { from: number, to: number }, + type?: string, +} diff --git a/dist/flow-typed/Lbry.js b/dist/flow-typed/Lbry.js index 0225fa2..7284455 100644 --- a/dist/flow-typed/Lbry.js +++ b/dist/flow-typed/Lbry.js @@ -170,6 +170,37 @@ declare type ChannelSignResponse = { signing_ts: string, }; +declare type CollectionCreateResponse = { + outputs: Array, + page: number, + page_size: number, + total_items: number, + total_pages: number, +} + +declare type CollectionListResponse = { + items: Array, + page: number, + page_size: number, + total_items: number, + total_pages: number, +}; + +declare type CollectionResolveResponse = { + items: Array, + total_items: number, +}; + +declare type CollectionResolveOptions = { + claim_id: string, +}; + +declare type CollectionListOptions = { + page: number, + page_size: number, + resolve?: boolean, +}; + declare type FileListResponse = { items: Array, page: number, @@ -288,6 +319,10 @@ declare type LbryTypes = { support_abandon: (params: {}) => Promise, stream_repost: (params: StreamRepostOptions) => Promise, purchase_list: (params: PurchaseListOptions) => Promise, + collection_resolve: (params: CollectionResolveOptions) => Promise, + collection_list: (params: CollectionListOptions) => Promise, + collection_create: (params: {}) => Promise, + collection_update: (params: {}) => Promise, // File fetching and manipulation file_list: (params: {}) => Promise, diff --git a/flow-typed/Claim.js b/flow-typed/Claim.js index 655a4bc..e03eeae 100644 --- a/flow-typed/Claim.js +++ b/flow-typed/Claim.js @@ -1,11 +1,15 @@ // @flow -declare type Claim = StreamClaim | ChannelClaim; +declare type Claim = StreamClaim | ChannelClaim | CollectionClaim; declare type ChannelClaim = GenericClaim & { value: ChannelMetadata, }; +declare type CollectionClaim = GenericClaim & { + value: CollectionMetadata, +}; + declare type StreamClaim = GenericClaim & { value: StreamMetadata, }; @@ -30,7 +34,7 @@ declare type GenericClaim = { short_url: string, // permanent_url with short id, no channel txid: string, // unique tx id type: 'claim' | 'update' | 'support', - value_type: 'stream' | 'channel', + value_type: 'stream' | 'channel' | 'collection', signing_channel?: ChannelClaim, reposted_claim?: GenericClaim, repost_channel_url?: string, @@ -74,6 +78,10 @@ declare type ChannelMetadata = GenericMetadata & { featured?: Array, }; +declare type CollectionMetadata = GenericMetadata & { + claims: Array, +} + declare type StreamMetadata = GenericMetadata & { license?: string, // License "title" ex: Creative Commons, Custom copyright license_url?: string, // Link to full license @@ -136,3 +144,71 @@ declare type PurchaseReceipt = { txid: string, type: 'purchase', }; + +declare type ClaimActionResolveInfo = { + [string]: { + stream: ?StreamClaim, + channel: ?ChannelClaim, + claimsInChannel: ?number, + collection: ?CollectionClaim, + }, +} + +declare type ChannelUpdateParams = { + claim_id: string, + bid?: string, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + replace?: boolean, + languages?: Array, + locations?: Array, + blocking?: boolean, +} + +declare type ChannelPublishParams = { + name: string, + bid: string, + blocking?: true, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + languages?: Array, +} + +declare type CollectionUpdateParams = { + claim_id: string, + claim_ids?: Array, + bid?: string, + title?: string, + cover_url?: string, + thumbnail_url?: string, + description?: string, + website_url?: string, + email?: string, + tags?: Array, + replace?: boolean, + languages?: Array, + locations?: Array, + blocking?: boolean, +} + +declare type CollectionPublishParams = { + name: string, + bid: string, + claim_ids: Array, + blocking?: true, + title?: string, + thumbnail_url?: string, + description?: string, + tags?: Array, + languages?: Array, +} diff --git a/flow-typed/Collections.js b/flow-typed/Collections.js new file mode 100644 index 0000000..c2eba63 --- /dev/null +++ b/flow-typed/Collections.js @@ -0,0 +1,41 @@ +declare type CollectionUpdateParams = { + remove?: boolean, + claims?: Array, + name?: string, + order?: { from: number, to: number }, +} + +declare type Collection = { + id: string, + items: Array, + name: string, + type: string, + updatedAt: number, + totalItems?: number, + sourceid?: string, // if copied, claimId of original collection +}; + +declare type CollectionState = { + unpublished: CollectionGroup, + resolved: CollectionGroup, + pending: CollectionGroup, + edited: CollectionGroup, + builtin: CollectionGroup, + saved: Array, + isResolvingCollectionById: { [string]: boolean }, + error?: string | null, +}; + +declare type CollectionGroup = { + [string]: Collection, +} + +declare type CollectionEditParams = { + claims?: Array, + remove?: boolean, + claimIds?: Array, + replace?: boolean, + order?: { from: number, to: number }, + type?: string, + name?: string, +} diff --git a/flow-typed/Lbry.js b/flow-typed/Lbry.js index 0225fa2..7284455 100644 --- a/flow-typed/Lbry.js +++ b/flow-typed/Lbry.js @@ -170,6 +170,37 @@ declare type ChannelSignResponse = { signing_ts: string, }; +declare type CollectionCreateResponse = { + outputs: Array, + page: number, + page_size: number, + total_items: number, + total_pages: number, +} + +declare type CollectionListResponse = { + items: Array, + page: number, + page_size: number, + total_items: number, + total_pages: number, +}; + +declare type CollectionResolveResponse = { + items: Array, + total_items: number, +}; + +declare type CollectionResolveOptions = { + claim_id: string, +}; + +declare type CollectionListOptions = { + page: number, + page_size: number, + resolve?: boolean, +}; + declare type FileListResponse = { items: Array, page: number, @@ -288,6 +319,10 @@ declare type LbryTypes = { support_abandon: (params: {}) => Promise, stream_repost: (params: StreamRepostOptions) => Promise, purchase_list: (params: PurchaseListOptions) => Promise, + collection_resolve: (params: CollectionResolveOptions) => Promise, + collection_list: (params: CollectionListOptions) => Promise, + collection_create: (params: {}) => Promise, + collection_update: (params: {}) => Promise, // File fetching and manipulation file_list: (params: {}) => Promise, diff --git a/src/constants/action_types.js b/src/constants/action_types.js index 5e3f672..bf16da2 100644 --- a/src/constants/action_types.js +++ b/src/constants/action_types.js @@ -102,6 +102,9 @@ 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 FETCH_CHANNEL_LIST_FAILED = 'FETCH_CHANNEL_LIST_FAILED'; +export const FETCH_COLLECTION_LIST_STARTED = 'FETCH_COLLECTION_LIST_STARTED'; +export const FETCH_COLLECTION_LIST_COMPLETED = 'FETCH_COLLECTION_LIST_COMPLETED'; +export const FETCH_COLLECTION_LIST_FAILED = 'FETCH_COLLECTION_LIST_FAILED'; export const CREATE_CHANNEL_STARTED = 'CREATE_CHANNEL_STARTED'; export const CREATE_CHANNEL_COMPLETED = 'CREATE_CHANNEL_COMPLETED'; export const CREATE_CHANNEL_FAILED = 'CREATE_CHANNEL_FAILED'; @@ -111,6 +114,7 @@ export const UPDATE_CHANNEL_FAILED = 'UPDATE_CHANNEL_FAILED'; export const IMPORT_CHANNEL_STARTED = 'IMPORT_CHANNEL_STARTED'; export const IMPORT_CHANNEL_COMPLETED = 'IMPORT_CHANNEL_COMPLETED'; export const IMPORT_CHANNEL_FAILED = 'IMPORT_CHANNEL_FAILED'; +export const CLEAR_CHANNEL_ERRORS = 'CLEAR_CHANNEL_ERRORS'; export const PUBLISH_STARTED = 'PUBLISH_STARTED'; export const PUBLISH_COMPLETED = 'PUBLISH_COMPLETED'; export const PUBLISH_FAILED = 'PUBLISH_FAILED'; @@ -129,7 +133,6 @@ export const CLAIM_REPOST_STARTED = 'CLAIM_REPOST_STARTED'; export const CLAIM_REPOST_COMPLETED = 'CLAIM_REPOST_COMPLETED'; export const CLAIM_REPOST_FAILED = 'CLAIM_REPOST_FAILED'; export const CLEAR_REPOST_ERROR = 'CLEAR_REPOST_ERROR'; -export const CLEAR_CHANNEL_ERRORS = 'CLEAR_CHANNEL_ERRORS'; export const CHECK_PUBLISH_NAME_STARTED = 'CHECK_PUBLISH_NAME_STARTED'; export const CHECK_PUBLISH_NAME_COMPLETED = 'CHECK_PUBLISH_NAME_COMPLETED'; export const UPDATE_PENDING_CLAIMS = 'UPDATE_PENDING_CLAIMS'; @@ -142,6 +145,27 @@ export const PURCHASE_LIST_STARTED = 'PURCHASE_LIST_STARTED'; export const PURCHASE_LIST_COMPLETED = 'PURCHASE_LIST_COMPLETED'; export const PURCHASE_LIST_FAILED = 'PURCHASE_LIST_FAILED'; +export const COLLECTION_PUBLISH_STARTED = 'COLLECTION_PUBLISH_STARTED'; +export const COLLECTION_PUBLISH_COMPLETED = 'COLLECTION_PUBLISH_COMPLETED'; +export const COLLECTION_PUBLISH_FAILED = 'COLLECTION_PUBLISH_FAILED'; +export const COLLECTION_PUBLISH_UPDATE_STARTED = 'COLLECTION_PUBLISH_UPDATE_STARTED'; +export const COLLECTION_PUBLISH_UPDATE_COMPLETED = 'COLLECTION_PUBLISH_UPDATE_COMPLETED'; +export const COLLECTION_PUBLISH_UPDATE_FAILED = 'COLLECTION_PUBLISH_UPDATE_FAILED'; +export const COLLECTION_PUBLISH_ABANDON_STARTED = 'COLLECTION_PUBLISH_ABANDON_STARTED'; +export const COLLECTION_PUBLISH_ABANDON_COMPLETED = 'COLLECTION_PUBLISH_ABANDON_COMPLETED'; +export const COLLECTION_PUBLISH_ABANDON_FAILED = 'COLLECTION_PUBLISH_ABANDON_FAILED'; +export const CLEAR_COLLECTION_ERRORS = 'CLEAR_COLLECTION_ERRORS'; +export const COLLECTION_ITEMS_RESOLVE_STARTED = 'COLLECTION_ITEMS_RESOLVE_STARTED'; +export const COLLECTION_ITEMS_RESOLVE_COMPLETED = 'COLLECTION_ITEMS_RESOLVE_COMPLETED'; +export const COLLECTION_ITEMS_RESOLVE_FAILED = 'COLLECTION_ITEMS_RESOLVE_FAILED'; +export const COLLECTION_NEW = 'COLLECTION_NEW'; +export const COLLECTION_DELETE = 'COLLECTION_DELETE'; +export const COLLECTION_PENDING = 'COLLECTION_PENDING'; +export const COLLECTION_EDIT = 'COLLECTION_EDIT'; +export const COLLECTION_COPY = 'COLLECTION_COPY'; +export const COLLECTION_SAVE = 'COLLECTION_SAVE'; +export const COLLECTION_ERROR = 'COLLECTION_ERROR'; + // Comments export const COMMENT_LIST_STARTED = 'COMMENT_LIST_STARTED'; export const COMMENT_LIST_COMPLETED = 'COMMENT_LIST_COMPLETED'; diff --git a/src/constants/collections.js b/src/constants/collections.js new file mode 100644 index 0000000..6b6d2f1 --- /dev/null +++ b/src/constants/collections.js @@ -0,0 +1,5 @@ +export const COLLECTION_ID = 'colid'; +export const COLLECTION_INDEX = 'colindex'; + +export const WATCH_LATER_ID = 'watchlater'; +export const FAVORITES_ID = 'favorites'; diff --git a/src/index.js b/src/index.js index d412d83..a7ca376 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ import * as TXO_LIST from 'constants/txo_list'; import * as SPEECH_URLS from 'constants/speech_urls'; import * as DAEMON_SETTINGS from 'constants/daemon_settings'; import * as SHARED_PREFERENCES from 'constants/shared_preferences'; +import * as COLLECTIONS_CONSTS from 'constants/collections'; import { DEFAULT_KNOWN_TAGS, DEFAULT_FOLLOWED_TAGS, MATURE_TAGS } from 'constants/tags'; import Lbry, { apiCall } from 'lbry'; import LbryFirst from 'lbry-first'; @@ -35,6 +36,7 @@ export { MATURE_TAGS, SPEECH_URLS, SHARED_PREFERENCES, + COLLECTIONS_CONSTS, }; // common @@ -57,6 +59,13 @@ export { buildSharedStateMiddleware } from 'redux/middleware/shared-state'; // actions export { doToast, doDismissToast, doError, doDismissError } from 'redux/actions/notifications'; +export { + doLocalCollectionCreate, + doFetchItemsInCollection, + doFetchItemsInCollections, + doCollectionEdit, + doLocalCollectionDelete, +} from 'redux/actions/collections'; export { doFetchClaimsByChannel, @@ -66,6 +75,7 @@ export { doResolveUris, doResolveUri, doFetchChannelListMine, + doFetchCollectionListMine, doCreateChannel, doUpdateChannel, doClaimSearch, @@ -76,6 +86,8 @@ export { doCheckPublishNameAvailability, doPurchaseList, doCheckPendingClaims, + doCollectionPublish, + doCollectionPublishUpdate, } from 'redux/actions/claims'; export { doClearPurchasedUriSuccess, doPurchaseUri, doFileGet } from 'redux/actions/file'; @@ -140,11 +152,34 @@ export { fileInfoReducer } from 'redux/reducers/file_info'; export { notificationsReducer } from 'redux/reducers/notifications'; export { publishReducer } from 'redux/reducers/publish'; export { walletReducer } from 'redux/reducers/wallet'; +export { collectionsReducer } from 'redux/reducers/collections'; // selectors export { makeSelectContentPositionForUri } from 'redux/selectors/content'; export { selectToast, selectError } from 'redux/selectors/notifications'; +export { + selectSavedCollectionIds, + selectBuiltinCollections, + selectResolvedCollections, + selectMyUnpublishedCollections, + selectMyEditedCollections, + selectMyPublishedCollections, + selectMyPublishedMixedCollections, + selectMyPublishedPlaylistCollections, + makeSelectEditedCollectionForId, + makeSelectPendingCollectionForId, + makeSelectPublishedCollectionForId, + makeSelectCollectionIsMine, + makeSelectMyPublishedCollectionForId, + makeSelectUnpublishedCollectionForId, + makeSelectCollectionForId, + makeSelectUrlsForCollectionId, + makeSelectClaimIdsForCollectionId, + makeSelectNameForCollectionId, + makeSelectIsResolvingCollectionForId, + makeSelectNextUrlForCollection, +} from 'redux/selectors/collections'; export { makeSelectClaimForUri, @@ -209,6 +244,8 @@ export { selectAllMyClaimsByOutpoint, selectMyClaimsOutpoints, selectFetchingMyChannels, + selectFetchingMyCollections, + selectMyCollectionIds, selectMyChannelClaims, selectResolvingUris, selectPlayingUri, @@ -237,6 +274,11 @@ export { selectFetchingMyPurchasesError, selectMyPurchasesCount, selectPurchaseUriSuccess, + makeSelectClaimIdForUri, + selectUpdatingCollection, + selectUpdateCollectionError, + selectCreatingCollection, + selectCreateCollectionError, } from 'redux/selectors/claims'; export { diff --git a/src/lbry.js b/src/lbry.js index bfa20eb..9a73384 100644 --- a/src/lbry.js +++ b/src/lbry.js @@ -90,6 +90,10 @@ const Lbry: LbryTypes = { support_create: params => daemonCallWithResult('support_create', params), support_list: params => daemonCallWithResult('support_list', params), stream_repost: params => daemonCallWithResult('stream_repost', params), + collection_resolve: params => daemonCallWithResult('collection_resolve', params), + collection_list: params => daemonCallWithResult('collection_list', params), + collection_create: params => daemonCallWithResult('collection_create', params), + collection_update: params => daemonCallWithResult('collection_update', params), // File fetching and manipulation file_list: (params = {}) => daemonCallWithResult('file_list', params), diff --git a/src/redux/actions/claims.js b/src/redux/actions/claims.js index cd0cb6d..98cee29 100644 --- a/src/redux/actions/claims.js +++ b/src/redux/actions/claims.js @@ -11,13 +11,20 @@ import { selectMyChannelClaims, selectPendingIds, selectClaimsById, + makeSelectClaimForClaimId, } from 'redux/selectors/claims'; + import { doFetchTxoPage } from 'redux/actions/wallet'; import { selectSupportsByOutpoint } from 'redux/selectors/wallet'; import { creditsToString } from 'util/format-credits'; import { batchActions } from 'util/batch-actions'; import { createNormalizedClaimSearchKey } from 'util/claim'; import { PAGE_SIZE } from 'constants/claim'; +import { + makeSelectEditedCollectionForId, + selectPendingCollections, +} from 'redux/selectors/collections'; +import { doFetchItemsInCollection, doFetchItemsInCollections } from 'redux/actions/collections'; type ResolveEntries = Array<[string, GenericClaim]>; @@ -61,12 +68,16 @@ export function doResolveUris( stream: ?StreamClaim, channel: ?ChannelClaim, claimsInChannel: ?number, + collection: ?CollectionClaim, }, } = {}; + const collectionIds: Array = []; + return Lbry.resolve({ urls: urisToResolve, ...options }).then( async(result: ResolveResponse) => { let repostedResults = {}; + const collectionClaimIdsToResolve = []; const repostsToResolve = []; const fallbackResolveInfo = { stream: null, @@ -80,6 +91,7 @@ export function doResolveUris( // https://github.com/facebook/flow/issues/2221 if (uriResolveInfo) { if (uriResolveInfo.error) { + // $FlowFixMe resolveInfo[uri] = { ...fallbackResolveInfo }; } else { if (checkReposts) { @@ -96,6 +108,10 @@ export function doResolveUris( result.channel = uriResolveInfo; // $FlowFixMe result.claimsInChannel = uriResolveInfo.meta.claims_in_channel; + } else if (uriResolveInfo.value_type === 'collection') { + result.collection = uriResolveInfo; + // $FlowFixMe + collectionIds.push(uriResolveInfo.claim_id); } else { result.stream = uriResolveInfo; if (uriResolveInfo.signing_channel) { @@ -127,6 +143,13 @@ export function doResolveUris( type: ACTIONS.RESOLVE_URIS_COMPLETED, data: { resolveInfo }, }); + + if (collectionIds.length) { + dispatch(doFetchItemsInCollections({ collectionIds: collectionIds, pageSize: 5 })); + } + // now collection claims are added, get their stuff + // if collections: doResolveCollections(claimIds) + return result; } ); @@ -573,11 +596,43 @@ export function doFetchChannelListMine( }; } +export function doFetchCollectionListMine(page: number = 1, pageSize: number = 99999) { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.FETCH_COLLECTION_LIST_STARTED, + }); + + const callback = (response: CollectionListResponse) => { + const { items } = response; + dispatch({ + type: ACTIONS.FETCH_COLLECTION_LIST_COMPLETED, + data: { claims: items }, + }); + dispatch( + doFetchItemsInCollections({ + collectionIds: items.map(claim => claim.claim_id), + page_size: 5, + }) + ); + // update or fetch collections? + }; + + const failure = error => { + dispatch({ + type: ACTIONS.FETCH_COLLECTION_LIST_FAILED, + data: error, + }); + }; + + Lbry.collection_list({ page, page_size: pageSize, resolve_claims: 1 }).then(callback, failure); + }; +} + export function doClaimSearch( options: { page_size: number, page: number, - no_totals: boolean, + no_totals?: boolean, any_tags?: Array, claim_ids?: Array, channel_ids?: Array, @@ -618,7 +673,8 @@ export function doClaimSearch( pageSize: options.page_size, }, }); - return true; + // was return true + return resolveInfo; }; const failure = err => { @@ -679,6 +735,128 @@ export function doRepost(options: StreamRepostOptions) { }; } +export function doCollectionPublish( + options: { + name: string, + bid: string, + blocking: true, + title?: string, + channel_id?: string, + thumbnail_url?: string, + description?: string, + tags?: Array, + languages?: Array, + claims: Array, + }, + localId: string +) { + return (dispatch: Dispatch) => { + // $FlowFixMe + return new Promise(resolve => { + dispatch({ + type: ACTIONS.COLLECTION_PUBLISH_STARTED, + }); + + function success(response) { + const collectionClaim = response.outputs[0]; + dispatch( + batchActions( + { + type: ACTIONS.COLLECTION_PUBLISH_COMPLETED, + }, + // shift unpublished collection to pending collection with new publish id + // recent publish won't resolve this second. handle it in checkPending + { + type: ACTIONS.COLLECTION_PENDING, + data: { localId: localId, claimId: collectionClaim.claim_id }, + }, + { + type: ACTIONS.UPDATE_PENDING_CLAIMS, + data: { + claims: [collectionClaim], + }, + } + ) + ); + dispatch(doCheckPendingClaims()); + dispatch(doFetchCollectionListMine(1, 10)); + resolve(collectionClaim); + } + + function failure(error) { + dispatch({ + type: ACTIONS.COLLECTION_PUBLISH_FAILED, + data: { + error: error.message, + }, + }); + } + + Lbry.collection_create(options).then(success, failure); + }); + }; +} + +export function doCollectionPublishUpdate(options: { + bid?: string, + blocking?: true, + title?: string, + thumbnail_url?: string, + description?: string, + claim_id: string, + tags?: Array, + languages?: Array, +}) { + return (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + // select claim forclaim_id + // get publish params from claim + // $FlowFixMe + + const collectionClaim = makeSelectClaimForClaimId(options.claim_id)(state); + // TODO: add old claim entries to params + const editItems = makeSelectEditedCollectionForId(options.claim_id)(state); + const oldParams: CollectionUpdateParams = { + bid: collectionClaim.amount, + }; + // $FlowFixMe + return new Promise(resolve => { + dispatch({ + type: ACTIONS.COLLECTION_PUBLISH_UPDATE_STARTED, + }); + + function success(response) { + const collectionClaim = response.outputs[0]; + dispatch({ + type: ACTIONS.COLLECTION_PUBLISH_UPDATE_COMPLETED, + data: { + collectionClaim, + }, + }); + dispatch({ + type: ACTIONS.UPDATE_PENDING_CLAIMS, + data: { + claims: [collectionClaim], + }, + }); + dispatch(doFetchCollectionListMine(1, 10)); + resolve(collectionClaim); + } + + function failure(error) { + dispatch({ + type: ACTIONS.COLLECTION_PUBLISH_UPDATE_FAILED, + data: { + error: error.message, + }, + }); + } + + Lbry.collection_update(options).then(success, failure); + }); + }; +} + export function doCheckPublishNameAvailability(name: string) { return (dispatch: Dispatch) => { dispatch({ @@ -750,6 +928,7 @@ export const doCheckPendingClaims = (onConfirmed: Function) => ( const checkClaimList = () => { const state = getState(); const pendingIdSet = new Set(selectPendingIds(state)); + const pendingCollections = selectPendingCollections(state); Lbry.claim_list({ page: 1, page_size: 10 }) .then(result => { const claims = result.items; @@ -758,6 +937,9 @@ export const doCheckPendingClaims = (onConfirmed: Function) => ( const { claim_id: claimId } = claim; if (claim.confirmations > 0 && pendingIdSet.has(claimId)) { pendingIdSet.delete(claimId); + if (Object.keys(pendingCollections).includes(claim.claim_id)) { + dispatch(doFetchItemsInCollection({ collectionId: claim.claim_id })); + } claimsToConfirm.push(claim); if (onConfirmed) { onConfirmed(claim); diff --git a/src/redux/actions/collections.js b/src/redux/actions/collections.js new file mode 100644 index 0000000..55a8c98 --- /dev/null +++ b/src/redux/actions/collections.js @@ -0,0 +1,459 @@ +// @flow +import * as ACTIONS from 'constants/action_types'; +import { v4 as uuid } from 'uuid'; +import Lbry from 'lbry'; +import { doClaimSearch } from 'redux/actions/claims'; +import { makeSelectClaimForClaimId } from 'redux/selectors/claims'; +import { + makeSelectCollectionForId, + // makeSelectPublishedCollectionForId, // for "save" or "copy" action + makeSelectMyPublishedCollectionForId, + makeSelectUnpublishedCollectionForId, + makeSelectEditedCollectionForId, +} from 'redux/selectors/collections'; +const WATCH_LATER_ID = 'watchlater'; +const FAVORITES_ID = 'favorites'; + +const BUILTIN_LISTS = [WATCH_LATER_ID, FAVORITES_ID]; + +const getTimestamp = () => { + return Math.floor(Date.now() / 1000); +}; + +// maybe take items param +export const doLocalCollectionCreate = ( + name: string, + collectionItems: string, + sourceId: string +) => (dispatch: Dispatch) => { + return dispatch({ + type: ACTIONS.COLLECTION_NEW, + data: { + entry: { + id: uuid(), // start with a uuid, this becomes a claimId after publish + name: name, + updatedAt: getTimestamp(), + items: collectionItems || [], + sourceId: sourceId, + }, + }, + }); +}; + +export const doLocalCollectionDelete = (id: string) => (dispatch: Dispatch) => { + return dispatch({ + type: ACTIONS.COLLECTION_DELETE, + data: { + id: id, + }, + }); +}; + +// Given a collection, save its collectionId to be resolved and displayed in Library +// export const doCollectionSave = ( +// id: string, +// ) => (dispatch: Dispatch) => { +// return dispatch({ +// type: ACTIONS.COLLECTION_SAVE, +// data: { +// id: id, +// }, +// }); +// }; + +// Given a collection and name, copy it to a local private collection with a name +// export const doCollectionCopy = ( +// id: string, +// ) => (dispatch: Dispatch) => { +// return dispatch({ +// type: ACTIONS.COLLECTION_COPY, +// data: { +// id: id, +// }, +// }); +// }; + +export const doFetchItemsInCollections = ( + resolveItemsOptions: { + collectionIds: Array, + pageSize?: number, + }, + resolveStartedCallback?: () => void +) => async (dispatch: Dispatch, getState: GetState) => { + let state = getState(); + // for each collection id, + // make sure the collection is resolved, the items are resolved, and build the collection objects + + const { collectionIds, pageSize } = resolveItemsOptions; + + dispatch({ + type: ACTIONS.COLLECTION_ITEMS_RESOLVE_STARTED, + data: { ids: collectionIds }, + }); + + if (resolveStartedCallback) resolveStartedCallback(); + + const collectionIdsToSearch = collectionIds.filter(claimId => !state.claims.byId[claimId]); + if (collectionIdsToSearch.length) { + let claimSearchOptions = { claim_ids: collectionIdsToSearch, page: 1, page_size: 9999 }; + await dispatch(doClaimSearch(claimSearchOptions)); + } + const invalidCollectionIds = []; + const stateAfterClaimSearch = getState(); + + async function resolveCollectionItems(claimId, totalItems, pageSize) { + // take [ {}, {} ], return {} + // only need items [ Claim... ] and total_items + const mergeResults = (arrayOfResults: Array<{ items: any, total_items: number }>) => { + const mergedResults: { items: Array, total_items: number } = { + items: [], + total_items: 0, + }; + arrayOfResults.forEach(result => { + mergedResults.items = mergedResults.items.concat(result.items); + mergedResults.total_items = result.total_items; + }); + return mergedResults; + }; + + try { + const BATCH_SIZE = 10; // up batch size when sdk bug fixed + const batches = []; + let fetchResult; + if (!pageSize) { + // batch all + for (let i = 0; i < Math.ceil(totalItems / BATCH_SIZE); i++) { + batches[i] = Lbry.collection_resolve({ + claim_id: claimId, + page: i + 1, + page_size: BATCH_SIZE, + }); + } + const resultArray = await Promise.all(batches); + fetchResult = mergeResults(resultArray); + } else { + fetchResult = await Lbry.collection_resolve({ + claim_id: claimId, + page: 1, + page_size: pageSize, + }); + } + // $FlowFixMe + const itemsById: { claimId: string, items?: ?Array } = { claimId: claimId }; + if (fetchResult.items) { + itemsById.items = fetchResult.items; + } else { + itemsById.items = null; + } + return itemsById; + } catch (e) { + return { + claimId: claimId, + items: null, + }; + } + } + + const promises = []; + collectionIds.forEach(collectionId => { + const claim = makeSelectClaimForClaimId(collectionId)(stateAfterClaimSearch); + if (!claim) { + invalidCollectionIds.push(collectionId); + } else { + const claimCount = claim.value.claims && claim.value.claims.length; + if (pageSize) { + promises.push(resolveCollectionItems(collectionId, claimCount, pageSize)); + } else { + promises.push(resolveCollectionItems(collectionId, claimCount)); + } + } + }); + + // $FlowFixMe + const resolvedCollectionItemsById: Array<{ + claimId: string, + items: ?Array, + }> = await Promise.all(promises); + + function processClaims(resultClaimsByUri) { + const processedClaims = {}; + Object.entries(resultClaimsByUri).forEach(([uri, uriResolveInfo]) => { + // Flow has terrible Object.entries support + // https://github.com/facebook/flow/issues/2221 + if (uriResolveInfo) { + let result = {}; + if (uriResolveInfo.value_type === 'channel') { + result.channel = uriResolveInfo; + // $FlowFixMe + result.claimsInChannel = uriResolveInfo.meta.claims_in_channel; + // ALSO SKIP COLLECTIONS + } else { + result.stream = uriResolveInfo; + if (uriResolveInfo.signing_channel) { + result.channel = uriResolveInfo.signing_channel; + result.claimsInChannel = + (uriResolveInfo.signing_channel.meta && + uriResolveInfo.signing_channel.meta.claims_in_channel) || + 0; + } + } + // $FlowFixMe + processedClaims[uri] = result; + } + }); + return processedClaims; + } + + const newCollectionItemsById = {}; + const flatResolvedCollectionItems = {}; + resolvedCollectionItemsById.forEach(entry => { + // $FlowFixMe + const collectionItems: Array = entry.items; + const collectionId = entry.claimId; + if (collectionItems) { + const claim = makeSelectClaimForClaimId(collectionId)(stateAfterClaimSearch); + + const editedCollection = makeSelectEditedCollectionForId(collectionId)(stateAfterClaimSearch); + const { name, timestamp } = claim || {}; + const valueTypes = new Set(); + const streamTypes = new Set(); + + let items = []; + collectionItems.forEach(collectionItem => { + // here's where we would just items.push(collectionItem.permanent_url + items.push(collectionItem.permanent_url); + valueTypes.add(collectionItem.value_type); + if (collectionItem.value.stream_type) { + streamTypes.add(collectionItem.value.stream_type); + } + flatResolvedCollectionItems[collectionItem.canonical_url] = collectionItem; + }); + const isPlaylist = + valueTypes.size === 1 && + valueTypes.has('stream') && + ((streamTypes.size === 1 && (streamTypes.has('audio') || streamTypes.has('video'))) || + (streamTypes.size === 2 && (streamTypes.has('audio') && streamTypes.has('video')))); + + newCollectionItemsById[collectionId] = { + items, + id: collectionId, + name: name, + itemCount: claim.value.claims.length, + type: isPlaylist ? 'playlist' : 'collection', + updatedAt: timestamp, + }; + // clear any stale edits + if (editedCollection && timestamp > editedCollection['updatedAt']) { + dispatch({ + type: ACTIONS.COLLECTION_DELETE, + data: { + id: collectionId, + collectionKey: 'edited', + }, + }); + } + } else { + // no collection items? probably in pending. + } + }); + const processedClaimsByUri = processClaims(flatResolvedCollectionItems); + + dispatch({ + type: ACTIONS.RESOLVE_URIS_COMPLETED, + data: { resolveInfo: processedClaimsByUri }, + }); + + dispatch({ + type: ACTIONS.COLLECTION_ITEMS_RESOLVE_COMPLETED, + data: { + resolvedCollections: newCollectionItemsById, + failedCollectionIds: invalidCollectionIds, + }, + }); +}; + +export const doFetchItemsInCollection = ( + options: { collectionId: string, pageSize?: number }, + cb?: () => void +) => { + const { collectionId, pageSize } = options; + const newOptions: { collectionIds: Array, pageSize?: number } = { + collectionIds: [collectionId], + }; + if (pageSize) newOptions.pageSize = pageSize; + return doFetchItemsInCollections(newOptions, cb); +}; + +export const doCollectionEdit = (collectionId: string, params: CollectionEditParams) => async ( + dispatch: Dispatch, + getState: GetState +) => { + const state = getState(); + const collection: Collection = makeSelectCollectionForId(collectionId)(state); + const editedCollection: Collection = makeSelectEditedCollectionForId(collectionId)(state); + const unpublishedCollection: Collection = makeSelectUnpublishedCollectionForId(collectionId)( + state + ); + const publishedCollection: Collection = makeSelectMyPublishedCollectionForId(collectionId)(state); + + const generateCollectionItemsFromSearchResult = results => { + return ( + Object.values(results) + // $FlowFixMe + .reduce( + ( + acc, + cur: { + stream: ?StreamClaim, + channel: ?ChannelClaim, + claimsInChannel: ?number, + collection: ?CollectionClaim, + } + ) => { + let url; + if (cur.stream) { + url = cur.stream.permanent_url; + } else if (cur.channel) { + url = cur.channel.permanent_url; + } else if (cur.collection) { + url = cur.collection.permanent_url; + } else { + return acc; + } + acc.push(url); + return acc; + }, + [] + ) + ); + }; + + if (!collection) { + return dispatch({ + type: ACTIONS.COLLECTION_ERROR, + data: { + message: 'collection does not exist', + }, + }); + } + + let currentItems = collection.items ? collection.items.concat() : []; + const { claims: passedClaims, order, claimIds, replace, remove, type } = params; + + const collectionType = type || 'collection'; + let newItems: Array = currentItems; + + if (passedClaims) { + if (remove) { + const passedUrls = passedClaims.map(claim => claim.permanent_url); + // $FlowFixMe // need this? + newItems = currentItems.filter((item: string) => !passedUrls.includes(item)); + } else { + passedClaims.forEach(claim => newItems.push(claim.permanent_url)); + } + } + + if (claimIds) { + const batches = []; + if (claimIds.length > 50) { + for (let i = 0; i < Math.ceil(claimIds.length / 50); i++) { + batches[i] = claimIds.slice(i * 50, (i + 1) * 50); + } + } else { + batches[0] = claimIds; + } + const resultArray = await Promise.all( + batches.map(batch => { + let options = { claim_ids: batch, page: 1, page_size: 50 }; + return dispatch(doClaimSearch(options)); + }) + ); + + const searchResults = Object.assign({}, ...resultArray); + + if (replace) { + newItems = generateCollectionItemsFromSearchResult(searchResults); + } else { + newItems = currentItems.concat(generateCollectionItemsFromSearchResult(searchResults)); + } + } + + if (order) { + const [movedItem] = currentItems.splice(order.from, 1); + currentItems.splice(order.to, 0, movedItem); + } + + if (editedCollection) { + if (publishedCollection.items.join(',') === newItems.join(',')) { + // delete edited if newItems are the same as publishedItems + dispatch({ + type: ACTIONS.COLLECTION_DELETE, + data: { + id: collectionId, + collectionKey: 'edited', + }, + }); + } else { + dispatch({ + type: ACTIONS.COLLECTION_EDIT, + data: { + id: collectionId, + collectionKey: 'edited', + collection: { + items: newItems, + id: collectionId, + name: params.name || collection.name, + updatedAt: getTimestamp(), + type: collectionType, + }, + }, + }); + } + } else if (publishedCollection) { + dispatch({ + type: ACTIONS.COLLECTION_EDIT, + data: { + id: collectionId, + collectionKey: 'edited', + collection: { + items: newItems, + id: collectionId, + name: params.name || collection.name, + updatedAt: getTimestamp(), + type: collectionType, + }, + }, + }); + } else if (BUILTIN_LISTS.includes(collectionId)) { + dispatch({ + type: ACTIONS.COLLECTION_EDIT, + data: { + id: collectionId, + collectionKey: 'builtin', + collection: { + items: newItems, + id: collectionId, + name: params.name || collection.name, + updatedAt: getTimestamp(), + type: collectionType, + }, + }, + }); + } else if (unpublishedCollection) { + dispatch({ + type: ACTIONS.COLLECTION_EDIT, + data: { + id: collectionId, + collectionKey: 'unpublished', + collection: { + items: newItems, + id: collectionId, + name: params.name || collection.name, + updatedAt: getTimestamp(), + type: collectionType, + }, + }, + }); + } + return true; +}; diff --git a/src/redux/actions/sync.js b/src/redux/actions/sync.js index 14c7f00..d1812a5 100644 --- a/src/redux/actions/sync.js +++ b/src/redux/actions/sync.js @@ -13,6 +13,9 @@ type SharedData = { settings?: any, app_welcome_version?: number, sharing_3P?: boolean, + unpublishedCollectionTest: CollectionGroup, + builtinCollectionTest: CollectionGroup, + savedCollectionTest: Array, }, }; @@ -27,6 +30,9 @@ function extractUserState(rawObj: SharedData) { settings, app_welcome_version, sharing_3P, + unpublishedCollectionTest, + builtinCollectionTest, + savedCollectionTest, } = rawObj.value; return { @@ -38,6 +44,9 @@ function extractUserState(rawObj: SharedData) { ...(settings ? { settings } : {}), ...(app_welcome_version ? { app_welcome_version } : {}), ...(sharing_3P ? { sharing_3P } : {}), + ...(unpublishedCollectionTest ? { unpublishedCollectionTest } : {}), + ...(builtinCollectionTest ? { builtinCollectionTest } : {}), + ...(savedCollectionTest ? { savedCollectionTest } : {}), }; } @@ -55,6 +64,9 @@ export function doPopulateSharedUserState(sharedSettings: any) { settings, app_welcome_version, sharing_3P, + unpublishedCollectionTest, + builtinCollectionTest, + savedCollectionTest, } = extractUserState(sharedSettings); dispatch({ type: ACTIONS.USER_STATE_POPULATE, @@ -67,6 +79,9 @@ export function doPopulateSharedUserState(sharedSettings: any) { settings, welcomeVersion: app_welcome_version, allowAnalytics: sharing_3P, + unpublishedCollectionTest, + builtinCollectionTest, + savedCollectionTest, }, }); }; diff --git a/src/redux/reducers/claims.js b/src/redux/reducers/claims.js index 1b77d3a..21b3c01 100644 --- a/src/redux/reducers/claims.js +++ b/src/redux/reducers/claims.js @@ -13,6 +13,7 @@ import mergeClaim from 'util/merge-claim'; type State = { createChannelError: ?string, + createCollectionError: ?string, channelClaimCounts: { [string]: number }, claimsByUri: { [string]: string }, byId: { [string]: Claim }, @@ -21,9 +22,11 @@ type State = { reflectingById: { [string]: ReflectingUpdate }, myClaims: ?Array, myChannelClaims: ?Array, + myCollectionClaims: ?Array, abandoningById: { [string]: boolean }, fetchingChannelClaims: { [string]: number }, fetchingMyChannels: boolean, + fetchingMyCollections: boolean, fetchingClaimSearchByQuery: { [string]: boolean }, purchaseUriSuccess: boolean, myPurchases: ?Array, @@ -34,6 +37,7 @@ type State = { claimSearchByQuery: { [string]: Array }, claimSearchByQueryLastPageReached: { [string]: Array }, creatingChannel: boolean, + creatingCollection: boolean, paginatedClaimsByChannel: { [string]: { all: Array, @@ -43,7 +47,9 @@ type State = { }, }, updateChannelError: ?string, + updateCollectionError: ?string, updatingChannel: boolean, + updatingCollection: boolean, pendingChannelImport: string | boolean, repostLoading: boolean, repostError: ?string, @@ -66,6 +72,7 @@ const defaultState = { fetchingChannelClaims: {}, resolvingUris: [], myChannelClaims: undefined, + myCollectionClaims: [], myClaims: undefined, myPurchases: undefined, myPurchasesPageNumber: undefined, @@ -74,6 +81,7 @@ const defaultState = { fetchingMyPurchases: false, fetchingMyPurchasesError: undefined, fetchingMyChannels: false, + fetchingMyCollections: false, abandoningById: {}, pendingIds: [], reflectingById: {}, @@ -82,9 +90,13 @@ const defaultState = { claimSearchByQueryLastPageReached: {}, fetchingClaimSearchByQuery: {}, updateChannelError: '', + updateCollectionError: '', updatingChannel: false, creatingChannel: false, createChannelError: undefined, + updatingCollection: false, + creatingCollection: false, + createCollectionError: undefined, pendingChannelImport: false, repostLoading: false, repostError: undefined, @@ -100,15 +112,7 @@ const defaultState = { }; function handleClaimAction(state: State, action: any): State { - const { - resolveInfo, - }: { - [string]: { - stream: ?StreamClaim, - channel: ?ChannelClaim, - claimsInChannel: ?number, - }, - } = action.data; + const { resolveInfo }: ClaimActionResolveInfo = action.data; const byUri = Object.assign({}, state.claimsByUri); const byId = Object.assign({}, state.byId); @@ -119,7 +123,7 @@ function handleClaimAction(state: State, action: any): State { Object.entries(resolveInfo).forEach(([url: string, resolveResponse: ResolveResponse]) => { // $FlowFixMe - const { claimsInChannel, stream, channel: channelFromResolve } = resolveResponse; + const { claimsInChannel, stream, channel: channelFromResolve, collection } = resolveResponse; const channel = channelFromResolve || (stream && stream.signing_channel); if (stream) { @@ -165,8 +169,29 @@ function handleClaimAction(state: State, action: any): State { newResolvingUrls.delete(channel.permanent_url); } + if (collection) { + if (pendingIds.includes(collection.claim_id)) { + byId[collection.claim_id] = mergeClaim(collection, byId[collection.claim_id]); + } else { + byId[collection.claim_id] = collection; + } + byUri[url] = collection.claim_id; + + // If url isn't a canonical_url, make sure that is added too + byUri[collection.canonical_url] = collection.claim_id; + + // Also add the permanent_url here until lighthouse returns canonical_url for search results + byUri[collection.permanent_url] = collection.claim_id; + newResolvingUrls.delete(collection.canonical_url); + newResolvingUrls.delete(collection.permanent_url); + + if (collection.is_my_output) { + myClaimIds.add(collection.claim_id); + } + } + newResolvingUrls.delete(url); - if (!stream && !channel && !pendingIds.includes(byUri[url])) { + if (!stream && !channel && !collection && !pendingIds.includes(byUri[url])) { byUri[url] = null; } }); @@ -306,6 +331,57 @@ reducers[ACTIONS.FETCH_CHANNEL_LIST_FAILED] = (state: State, action: any): State }); }; +reducers[ACTIONS.FETCH_COLLECTION_LIST_STARTED] = (state: State): State => ({ + ...state, + fetchingMyCollections: true, +}); + +reducers[ACTIONS.FETCH_COLLECTION_LIST_COMPLETED] = (state: State, action: any): State => { + const { claims }: { claims: Array } = action.data; + const myClaims = state.myClaims || []; + let myClaimIds = new Set(myClaims); + const pendingIds = state.pendingIds || []; + let myCollectionClaims; + const byId = Object.assign({}, state.byId); + const byUri = Object.assign({}, state.claimsByUri); + + if (!claims.length) { + // $FlowFixMe + myCollectionClaims = null; + } else { + myCollectionClaims = new Set(state.myCollectionClaims); + claims.forEach(claim => { + const { meta } = claim; + const { canonical_url: canonicalUrl, permanent_url: permanentUrl, claim_id: claimId } = claim; + // maybe add info about items in collection + + byUri[canonicalUrl] = claimId; + byUri[permanentUrl] = claimId; + + // $FlowFixMe + myCollectionClaims.add(claimId); + // we don't want to overwrite a pending result with a resolve + if (!pendingIds.some(c => c === claimId)) { + byId[claimId] = claim; + } + myClaimIds.add(claimId); + }); + } + + return { + ...state, + byId, + claimsByUri: byUri, + fetchingMyCollections: false, + myCollectionClaims: myCollectionClaims ? Array.from(myCollectionClaims) : null, + myClaims: myClaimIds ? Array.from(myClaimIds) : null, + }; +}; + +reducers[ACTIONS.FETCH_COLLECTION_LIST_FAILED] = (state: State): State => { + return { ...state, fetchingMyCollections: false }; +}; + reducers[ACTIONS.FETCH_CHANNEL_CLAIMS_STARTED] = (state: State, action: any): State => { const { uri, page } = action.data; const fetchingChannelClaims = Object.assign({}, state.fetchingChannelClaims); @@ -520,6 +596,54 @@ reducers[ACTIONS.UPDATE_CHANNEL_FAILED] = (state: State, action: any): State => }); }; +reducers[ACTIONS.CLEAR_COLLECTION_ERRORS] = (state: State): State => ({ + ...state, + createCollectionError: null, + updateCollectionError: null, +}); + +reducers[ACTIONS.COLLECTION_PUBLISH_STARTED] = (state: State): State => ({ + ...state, + creatingCollection: true, + createCollectionError: null, +}); + +reducers[ACTIONS.COLLECTION_PUBLISH_COMPLETED] = (state: State, action: any): State => { + return Object.assign({}, state, { + creatingCollection: false, + }); +}; + +reducers[ACTIONS.COLLECTION_PUBLISH_FAILED] = (state: State, action: any): State => { + return Object.assign({}, state, { + creatingCollection: false, + createCollectionError: action.data.error, + }); +}; + +reducers[ACTIONS.COLLECTION_PUBLISH_UPDATE_STARTED] = (state: State, action: any): State => { + return Object.assign({}, state, { + updateCollectionError: '', + updatingCollection: true, + }); +}; + +reducers[ACTIONS.COLLECTION_PUBLISH_UPDATE_COMPLETED] = (state: State, action: any): State => { + return Object.assign({}, state, { + updateCollectionError: '', + updatingCollection: false, + }); +}; + +reducers[ACTIONS.COLLECTION_PUBLISH_UPDATE_FAILED] = (state: State, action: any): State => { + return Object.assign({}, state, { + updateCollectionError: action.data.error, + updatingCollection: false, + }); +}; + +// COLLECTION_PUBLISH_ABANDON_... + reducers[ACTIONS.IMPORT_CHANNEL_STARTED] = (state: State): State => Object.assign({}, state, { pendingChannelImports: true }); diff --git a/src/redux/reducers/collections.js b/src/redux/reducers/collections.js new file mode 100644 index 0000000..0f93e43 --- /dev/null +++ b/src/redux/reducers/collections.js @@ -0,0 +1,235 @@ +// @flow +import { handleActions } from 'util/redux-utils'; +import * as ACTIONS from 'constants/action_types'; +import * as COLLECTION_CONSTS from 'constants/collections'; + +const WATCH_LATER_ID = 'watchlater'; +const FAVORITES_ID = 'favorites'; + +const BUILTIN_LISTS = [WATCH_LATER_ID, FAVORITES_ID]; +const getTimestamp = () => { + return Math.floor(Date.now() / 1000); +}; + +const defaultState: CollectionState = { + builtin: { + watchlater: { + items: ['lbry://seriouspublish#c1b740eb88f96b465f65e5f1542564539df1c62e'], + id: WATCH_LATER_ID, + name: 'Watch Later', + updatedAt: getTimestamp(), + type: 'playlist', + }, + favorites: { + items: ['lbry://seriouspublish#c1b740eb88f96b465f65e5f1542564539df1c62e'], + id: FAVORITES_ID, + name: 'Favorites', + type: 'collection', + updatedAt: getTimestamp(), + }, + }, + resolved: {}, + unpublished: {}, // sync + edited: {}, + pending: {}, + saved: [], + isResolvingCollectionById: {}, + error: null, +}; + +const collectionsReducer = handleActions( + { + [ACTIONS.COLLECTION_NEW]: (state, action) => { + const { entry: params } = action.data; // { id:, items: Array} + // entry + const newListTemplate = { + id: params.id, + name: params.name, + items: [], + updatedAt: getTimestamp(), + type: params.type || 'mixed', + }; + + const newList = Object.assign({}, newListTemplate, { ...params }); + const { unpublished: lists } = state; + const newLists = Object.assign({}, lists, { [params.id]: newList }); + + return { + ...state, + unpublished: newLists, + }; + }, + + [ACTIONS.COLLECTION_DELETE]: (state, action) => { + const { id, collectionKey } = action.data; + const { edited: editList, unpublished: unpublishedList, pending: pendingList } = state; + const newEditList = Object.assign({}, editList); + const newUnpublishedList = Object.assign({}, unpublishedList); + + const newPendingList = Object.assign({}, pendingList); + + if (collectionKey && state[collectionKey] && state[collectionKey][id]) { + delete state[collectionKey][id]; + } else { + if (newEditList[id]) { + delete newEditList[id]; + } else if (newUnpublishedList[id]) { + delete newUnpublishedList[id]; + } else if (newPendingList[id]) { + delete newPendingList[id]; + } + } + return { + ...state, + edited: newEditList, + unpublished: newUnpublishedList, + pending: newPendingList, + }; + }, + + [ACTIONS.COLLECTION_PENDING]: (state, action) => { + const { localId, claimId } = action.data; + const { edited: editList, unpublished: unpublishedList, pending: pendingList } = state; + const newEditList = Object.assign({}, editList); + const newUnpublishedList = Object.assign({}, unpublishedList); + const newPendingList = Object.assign({}, pendingList); + + const isEdit = editList[localId]; + if (localId) { + // pending from unpublished -> published + // delete from local + newPendingList[claimId] = Object.assign( + {}, + newEditList[localId] || newUnpublishedList[localId] || {} + ); + if (isEdit) { + delete newEditList[localId]; + } else { + delete newUnpublishedList[localId]; + } + } else { + // pending from edited published -> published + if (isEdit) { + newPendingList[claimId] = Object.assign({}, newEditList[claimId]); + delete newEditList[claimId]; + } + } + + return { + ...state, + edited: newEditList, + unpublished: newUnpublishedList, + pending: newPendingList, + }; + }, + + [ACTIONS.COLLECTION_EDIT]: (state, action) => { + const { id, collectionKey, collection } = action.data; + + if (BUILTIN_LISTS.includes(id)) { + const { builtin: lists } = state; + return { + ...state, + [collectionKey]: { ...lists, [id]: collection }, + }; + } + + if (collectionKey === 'edited') { + const { edited: lists } = state; + return { + ...state, + edited: { ...lists, [id]: collection }, + }; + } + const { unpublished: lists } = state; + return { + ...state, + unpublished: { ...lists, [id]: collection }, + }; + }, + + [ACTIONS.COLLECTION_ERROR]: (state, action) => { + return Object.assign({}, state, { + error: action.data.message, + }); + }, + + [ACTIONS.COLLECTION_ITEMS_RESOLVE_STARTED]: (state, action) => { + const { ids } = action.data; + const { isResolvingCollectionById } = state; + const newResolving = Object.assign({}, isResolvingCollectionById); + ids.forEach(id => { + newResolving[id] = true; + }); + return Object.assign({}, state, { + ...state, + error: '', + isResolvingCollectionById: newResolving, + }); + }, + [ACTIONS.USER_STATE_POPULATE]: (state, action) => { + const { builtinCollectionTest, savedCollectionTest, unpublishedCollectionTest } = action.data; + return { + ...state, + unpublished: unpublishedCollectionTest || state.unpublished, + builtin: builtinCollectionTest || state.builtin, + saved: savedCollectionTest || state.saved, + }; + }, + [ACTIONS.COLLECTION_ITEMS_RESOLVE_COMPLETED]: (state, action) => { + const { resolvedCollections, failedCollectionIds } = action.data; + const { + pending: pendingList, + edited: editList, + isResolvingCollectionById, + resolved: lists, + } = state; + const resolvedIds = Object.keys(resolvedCollections); + const newResolving = Object.assign({}, isResolvingCollectionById); + if (resolvedCollections && resolvedCollections.length) { + resolvedIds.forEach(resolvedId => { + if (editList[resolvedId]) { + if (editList[resolvedId]['updatedAt'] < resolvedCollections[resolvedId]['updatedAt']) { + delete editList[resolvedId]; + } + } + delete newResolving[resolvedId]; + if (pendingList[resolvedId]) { + delete pendingList[resolvedId]; + } + }); + } + + if (failedCollectionIds && failedCollectionIds.length) { + failedCollectionIds.forEach(failedId => { + delete newResolving[failedId]; + }); + } + + const newLists = Object.assign({}, lists, resolvedCollections); + + return Object.assign({}, state, { + ...state, + pending: pendingList, + resolved: newLists, + isResolvingCollectionById: newResolving, + }); + }, + [ACTIONS.COLLECTION_ITEMS_RESOLVE_FAILED]: (state, action) => { + const { ids } = action.data; + const { isResolvingCollectionById } = state; + const newResolving = Object.assign({}, isResolvingCollectionById); + ids.forEach(id => { + delete newResolving[id]; + }); + return Object.assign({}, state, { + ...state, + isResolvingCollectionById: newResolving, + error: action.data.message, + }); + }, + }, + defaultState +); + +export { collectionsReducer }; diff --git a/src/redux/selectors/claims.js b/src/redux/selectors/claims.js index d7b151d..cca04fd 100644 --- a/src/redux/selectors/claims.js +++ b/src/redux/selectors/claims.js @@ -95,6 +95,12 @@ export const makeSelectClaimIsPending = (uri: string) => } ); +export const makeSelectClaimIdForUri = (uri: string) => + createSelector( + selectClaimIdsByUri, + claimIds => claimIds[uri] + ); + export const selectReflectingById = createSelector( selectState, state => state.reflectingById @@ -531,6 +537,11 @@ export const selectFetchingMyChannels = createSelector( state => state.fetchingMyChannels ); +export const selectFetchingMyCollections = createSelector( + selectState, + state => state.fetchingMyCollections +); + export const selectMyChannelClaims = createSelector( selectState, selectClaimsById, @@ -557,6 +568,11 @@ export const selectMyChannelUrls = createSelector( claims => (claims ? claims.map(claim => claim.canonical_url || claim.permanent_url) : undefined) ); +export const selectMyCollectionIds = createSelector( + selectState, + state => state.myCollectionClaims +); + export const selectResolvingUris = createSelector( selectState, state => state.resolvingUris || [] @@ -917,3 +933,23 @@ export const makeSelectStakedLevelForChannelUri = (uri: string) => return level; } ); + +export const selectUpdatingCollection = createSelector( + selectState, + state => state.updatingCollection +); + +export const selectUpdateCollectionError = createSelector( + selectState, + state => state.updateCollectionError +); + +export const selectCreatingCollection = createSelector( + selectState, + state => state.creatingCollection +); + +export const selectCreateCollectionError = createSelector( + selectState, + state => state.createCollectionError +); diff --git a/src/redux/selectors/collections.js b/src/redux/selectors/collections.js new file mode 100644 index 0000000..f895b3e --- /dev/null +++ b/src/redux/selectors/collections.js @@ -0,0 +1,203 @@ +// @flow +import { createSelector } from 'reselect'; +import { selectMyCollectionIds } from 'redux/selectors/claims'; +import { parseURI } from 'lbryURI'; + +const selectState = (state: { collections: CollectionState }) => state.collections; + +export const selectSavedCollectionIds = createSelector( + selectState, + collectionState => collectionState.saved +); + +export const selectBuiltinCollections = createSelector( + selectState, + state => state.builtin +); +export const selectResolvedCollections = createSelector( + selectState, + state => state.resolved +); + +export const selectMyUnpublishedCollections = createSelector( + selectState, + state => state.unpublished +); + +export const selectMyEditedCollections = createSelector( + selectState, + state => state.edited +); + +export const selectPendingCollections = createSelector( + selectState, + state => state.pending +); + +export const makeSelectEditedCollectionForId = (id: string) => + createSelector( + selectMyEditedCollections, + eLists => eLists[id] + ); + +export const makeSelectPendingCollectionForId = (id: string) => + createSelector( + selectPendingCollections, + pending => pending[id] + ); + +export const makeSelectPublishedCollectionForId = (id: string) => + createSelector( + selectResolvedCollections, + rLists => rLists[id] + ); + +export const makeSelectUnpublishedCollectionForId = (id: string) => + createSelector( + selectMyUnpublishedCollections, + rLists => rLists[id] + ); + +export const makeSelectCollectionIsMine = (id: string) => + createSelector( + selectMyCollectionIds, + selectMyUnpublishedCollections, + selectBuiltinCollections, + (publicIds, privateIds, builtinIds) => { + return Boolean(publicIds.includes(id) || privateIds[id] || builtinIds[id]); + } + ); + +// for library page, we want all published +export const selectMyPublishedCollections = createSelector( + selectResolvedCollections, + selectPendingCollections, + selectMyEditedCollections, + selectMyCollectionIds, + (resolved, pending, edited, myIds) => { + // all resolved in myIds, plus those in pending and edited + const myPublishedCollections = Object.fromEntries( + Object.entries(pending).concat( + Object.entries(edited) + .filter( + ([key, val]) => myIds.includes(key) + // $FlowFixMe + ) + .concat( + Object.entries(resolved).filter( + ([key, val]) => + myIds.includes(key) && + // $FlowFixMe + (!pending[key] && !edited[key]) + ) + ) + ) + ); + return myPublishedCollections; + } +); + +export const selectMyPublishedMixedCollections = createSelector( + selectMyPublishedCollections, + published => { + const myCollections = Object.fromEntries( + // $FlowFixMe + Object.entries(published).filter(([key, collection]) => { + // $FlowFixMe + return collection.type === 'collection'; + }) + ); + return myCollections; + } +); + +export const selectMyPublishedPlaylistCollections = createSelector( + selectMyPublishedCollections, + published => { + const myCollections = Object.fromEntries( + // $FlowFixMe + Object.entries(published).filter(([key, collection]) => { + // $FlowFixMe + return collection.type === 'playlist'; + }) + ); + return myCollections; + } +); + +export const makeSelectMyPublishedCollectionForId = (id: string) => + createSelector( + selectMyPublishedCollections, + myPublishedCollections => myPublishedCollections[id] + ); + +// export const selectSavedCollections = createSelector( +// selectResolvedCollections, +// selectSavedCollectionIds, +// (resolved, myIds) => { +// const mySavedCollections = Object.fromEntries( +// Object.entries(resolved).filter(([key, val]) => myIds.includes(key)) +// ); +// return mySavedCollections; +// } +// ); + +export const makeSelectIsResolvingCollectionForId = (id: string) => + createSelector( + selectState, + state => { + return state.isResolvingCollectionById[id]; + } + ); + +export const makeSelectCollectionForId = (id: string) => + createSelector( + selectBuiltinCollections, + selectResolvedCollections, + selectMyUnpublishedCollections, + selectMyEditedCollections, + selectPendingCollections, + (bLists, rLists, uLists, eLists, pLists) => { + const collection = bLists[id] || uLists[id] || eLists[id] || rLists[id] || pLists[id]; + return collection; + } + ); + +export const makeSelectUrlsForCollectionId = (id: string) => + createSelector( + makeSelectCollectionForId(id), + collection => collection && collection.items + ); + +export const makeSelectClaimIdsForCollectionId = (id: string) => + createSelector( + makeSelectCollectionForId(id), + collection => { + const items = (collection && collection.items) || []; + const ids = items.map(item => { + const { claimId } = parseURI(item); + return claimId; + }); + return ids; + } + ); + +export const makeSelectNextUrlForCollection = (id: string, index: number) => + createSelector( + makeSelectUrlsForCollectionId(id), + urls => { + const url = urls[index + 1]; + if (url) { + return url; + } + return null; + } + ); + +export const makeSelectNameForCollectionId = (id: string) => + createSelector( + makeSelectCollectionForId(id), + collection => { + return (collection && collection.name) || ''; + } + );