// @flow import * as ACTIONS from 'constants/action_types'; import * as ABANDON_STATES from 'constants/abandon_states'; import Lbry from 'lbry'; import { normalizeURI } from 'util/lbryURI'; import { doToast } from 'redux/actions/notifications'; import { selectMyClaimsRaw, selectResolvingUris, selectClaimsByUri, selectMyChannelClaims, selectPendingClaimsById, selectClaimIsMine, } 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 { makeSelectClaimIdsForCollectionId } from 'redux/selectors/collections'; import { doFetchItemsInCollections } from 'redux/actions/collections'; let onChannelConfirmCallback; let checkPendingInterval; export function doResolveUris( uris: Array, returnCachedClaims: boolean = false, resolveReposts: boolean = true ) { return (dispatch: Dispatch, getState: GetState) => { const normalizedUris = uris.map(normalizeURI); const state = getState(); const resolvingUris = selectResolvingUris(state); const claimsByUri = selectClaimsByUri(state); const urisToResolve = normalizedUris.filter((uri) => { if (resolvingUris.includes(uri)) { return false; } return returnCachedClaims ? !claimsByUri[uri] : true; }); if (urisToResolve.length === 0) { return; } const options: { include_is_my_output?: boolean, include_purchase_receipt: boolean } = { include_purchase_receipt: true, }; if (urisToResolve.length === 1) { options.include_is_my_output = true; } dispatch({ type: ACTIONS.RESOLVE_URIS_STARTED, data: { uris: normalizedUris }, }); const resolveInfo: { [string]: { stream: ?StreamClaim, channel: ?ChannelClaim, claimsInChannel: ?number, collection: ?CollectionClaim, }, } = {}; const collectionIds: Array = []; return Lbry.resolve({ urls: urisToResolve, ...options }).then(async (result: ResolveResponse) => { let repostedResults = {}; const repostsToResolve = []; const fallbackResolveInfo = { stream: null, claimsInChannel: null, channel: null, }; function processResult(result, resolveInfo = {}, checkReposts = false) { Object.entries(result).forEach(([uri, uriResolveInfo]) => { // Flow has terrible Object.entries support // https://github.com/facebook/flow/issues/2221 if (uriResolveInfo) { if (uriResolveInfo.error) { // $FlowFixMe resolveInfo[uri] = { ...fallbackResolveInfo }; } else { if (checkReposts) { if (uriResolveInfo.reposted_claim) { // $FlowFixMe const repostUrl = uriResolveInfo.reposted_claim.permanent_url; if (!resolvingUris.includes(repostUrl)) { repostsToResolve.push(repostUrl); } } } let result = {}; if (uriResolveInfo.value_type === 'channel') { 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) { result.channel = uriResolveInfo.signing_channel; result.claimsInChannel = (uriResolveInfo.signing_channel.meta && uriResolveInfo.signing_channel.meta.claims_in_channel) || 0; } } // $FlowFixMe resolveInfo[uri] = result; } } }); } processResult(result, resolveInfo, resolveReposts); if (repostsToResolve.length) { dispatch({ type: ACTIONS.RESOLVE_URIS_STARTED, data: { uris: repostsToResolve, debug: 'reposts' }, }); repostedResults = await Lbry.resolve({ urls: repostsToResolve, ...options }); } processResult(repostedResults, resolveInfo); dispatch({ type: ACTIONS.RESOLVE_URIS_COMPLETED, data: { resolveInfo }, }); if (collectionIds.length) { dispatch(doFetchItemsInCollections({ collectionIds: collectionIds, pageSize: 5 })); } return result; }); }; } export function doResolveUri(uri: string) { return doResolveUris([uri]); } export function doFetchClaimListMine( page: number = 1, pageSize: number = 99999, resolve: boolean = true, filterBy: Array = [] ) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.FETCH_CLAIM_LIST_MINE_STARTED, }); let claimTypes = ['stream', 'repost']; if (filterBy && filterBy.length !== 0) { claimTypes = claimTypes.filter((t) => filterBy.includes(t)); } // $FlowFixMe Lbry.claim_list({ page: page, page_size: pageSize, claim_type: claimTypes, resolve, }).then((result: StreamListResponse) => { dispatch({ type: ACTIONS.FETCH_CLAIM_LIST_MINE_COMPLETED, data: { result, resolve, }, }); }); }; } export function doAbandonTxo(txo: Txo, cb: (string) => void) { return (dispatch: Dispatch) => { if (cb) cb(ABANDON_STATES.PENDING); const isClaim = txo.type === 'claim'; const isSupport = txo.type === 'support' && txo.is_my_input === true; const isTip = txo.type === 'support' && txo.is_my_input === false; const data = isClaim ? { claimId: txo.claim_id } : { outpoint: `${txo.txid}:${txo.nout}` }; const startedActionType = isClaim ? ACTIONS.ABANDON_CLAIM_STARTED : ACTIONS.ABANDON_SUPPORT_STARTED; const completedActionType = isClaim ? ACTIONS.ABANDON_CLAIM_SUCCEEDED : ACTIONS.ABANDON_SUPPORT_COMPLETED; dispatch({ type: startedActionType, data, }); const errorCallback = () => { if (cb) cb(ABANDON_STATES.ERROR); dispatch( doToast({ message: isClaim ? 'Error abandoning your claim/support' : 'Error unlocking your tip', isError: true, }) ); }; const successCallback = () => { dispatch({ type: completedActionType, data, }); let abandonMessage; if (isClaim) { abandonMessage = __('Successfully abandoned your claim.'); } else if (isSupport) { abandonMessage = __('Successfully abandoned your support.'); } else { abandonMessage = __('Successfully unlocked your tip!'); } if (cb) cb(ABANDON_STATES.DONE); dispatch( doToast({ message: abandonMessage, }) ); }; const abandonParams: { claim_id?: string, txid?: string, nout?: number, } = { blocking: true, }; if (isClaim) { abandonParams['claim_id'] = txo.claim_id; } else { abandonParams['txid'] = txo.txid; abandonParams['nout'] = txo.nout; } let method; if (isSupport || isTip) { method = 'support_abandon'; } else if (isClaim) { const { normalized_name: claimName } = txo; method = claimName.startsWith('@') ? 'channel_abandon' : 'stream_abandon'; } if (!method) { console.error('No "method" chosen for claim or support abandon'); return; } Lbry[method](abandonParams).then(successCallback, errorCallback); }; } export function doAbandonClaim(claim: Claim, cb: (string) => void) { const { txid, nout } = claim; const outpoint = `${txid}:${nout}`; return (dispatch: Dispatch, getState: GetState) => { const state = getState(); const myClaims: Array = selectMyClaimsRaw(state); const mySupports: { [string]: Support } = selectSupportsByOutpoint(state); // A user could be trying to abandon a support or one of their claims const claimIsMine = selectClaimIsMine(state, claim); const claimToAbandon = claimIsMine ? claim : myClaims.find((claim) => claim.txid === txid && claim.nout === nout); const supportToAbandon = mySupports[outpoint]; if (!claimToAbandon && !supportToAbandon) { console.error('No associated support or claim with txid: ', txid); return; } const data = claimToAbandon ? { claimId: claimToAbandon.claim_id } : { outpoint: `${supportToAbandon.txid}:${supportToAbandon.nout}` }; const isClaim = !!claimToAbandon; const startedActionType = isClaim ? ACTIONS.ABANDON_CLAIM_STARTED : ACTIONS.ABANDON_SUPPORT_STARTED; const completedActionType = isClaim ? ACTIONS.ABANDON_CLAIM_SUCCEEDED : ACTIONS.ABANDON_SUPPORT_COMPLETED; dispatch({ type: startedActionType, data, }); const errorCallback = () => { dispatch( doToast({ message: isClaim ? 'Error abandoning your claim/support' : 'Error unlocking your tip', isError: true, }) ); if (cb) cb(ABANDON_STATES.ERROR); }; const successCallback = () => { dispatch({ type: completedActionType, data, }); if (cb) cb(ABANDON_STATES.DONE); let abandonMessage; if (isClaim) { abandonMessage = __('Successfully abandoned your claim.'); } else if (supportToAbandon) { abandonMessage = __('Successfully abandoned your support.'); } else { abandonMessage = __('Successfully unlocked your tip!'); } dispatch( doToast({ message: abandonMessage, }) ); dispatch(doFetchTxoPage()); }; const abandonParams = { txid, nout, blocking: true, }; let method; if (supportToAbandon) { method = 'support_abandon'; } else if (claimToAbandon) { const { name: claimName } = claimToAbandon; method = claimName.startsWith('@') ? 'channel_abandon' : 'stream_abandon'; } if (!method) { console.error('No "method" chosen for claim or support abandon'); return; } Lbry[method](abandonParams).then(successCallback, errorCallback); }; } export function doFetchClaimsByChannel(uri: string, page: number = 1) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.FETCH_CHANNEL_CLAIMS_STARTED, data: { uri, page }, }); Lbry.claim_search({ channel: uri, valid_channel_signature: true, page: page || 1, order_by: ['release_time'], include_is_my_output: true, include_purchase_receipt: true, }).then((result: ClaimSearchResponse) => { const { items: claims, total_items: claimsInChannel, page: returnedPage } = result; dispatch({ type: ACTIONS.FETCH_CHANNEL_CLAIMS_COMPLETED, data: { uri, claimsInChannel, claims: claims || [], page: returnedPage || undefined, }, }); }); }; } export function doClearChannelErrors() { return { type: ACTIONS.CLEAR_CHANNEL_ERRORS, }; } export function doCreateChannel(name: string, amount: number, optionalParams: any, onConfirm: any) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.CREATE_CHANNEL_STARTED, }); const createParams: { 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, } = { name, bid: creditsToString(amount), blocking: true, }; if (optionalParams) { if (optionalParams.title) { createParams.title = optionalParams.title; } if (optionalParams.coverUrl) { createParams.cover_url = optionalParams.coverUrl; } if (optionalParams.thumbnailUrl) { createParams.thumbnail_url = optionalParams.thumbnailUrl; } if (optionalParams.description) { createParams.description = optionalParams.description; } if (optionalParams.website) { createParams.website_url = optionalParams.website; } if (optionalParams.email) { createParams.email = optionalParams.email; } if (optionalParams.tags) { createParams.tags = optionalParams.tags.map((tag) => tag.name); } if (optionalParams.languages) { createParams.languages = optionalParams.languages; } } return ( Lbry.channel_create(createParams) // outputs[0] is the certificate // outputs[1] is the change from the tx, not in the app currently .then((result: ChannelCreateResponse) => { const channelClaim = result.outputs[0]; dispatch({ type: ACTIONS.CREATE_CHANNEL_COMPLETED, data: { channelClaim }, }); dispatch({ type: ACTIONS.UPDATE_PENDING_CLAIMS, data: { claims: [channelClaim], }, }); dispatch(doCheckPendingClaims(onConfirm)); return channelClaim; }) .catch((error) => { dispatch({ type: ACTIONS.CREATE_CHANNEL_FAILED, data: error.message, }); }) ); }; } export function doUpdateChannel(params: any, cb: any) { return (dispatch: Dispatch, getState: GetState) => { dispatch({ type: ACTIONS.UPDATE_CHANNEL_STARTED, }); const state = getState(); const myChannels = selectMyChannelClaims(state); const channelClaim = myChannels.find((myChannel) => myChannel.claim_id === params.claim_id); const updateParams = { claim_id: params.claim_id, bid: creditsToString(params.amount), title: params.title, cover_url: params.coverUrl, thumbnail_url: params.thumbnailUrl, description: params.description, website_url: params.website, email: params.email, tags: [], replace: true, languages: params.languages || [], locations: [], blocking: true, }; if (params.tags) { updateParams.tags = params.tags.map((tag) => tag.name); } // we'll need to remove these once we add locations/channels to channel page edit/create options if (channelClaim && channelClaim.value && channelClaim.value.locations) { updateParams.locations = channelClaim.value.locations; } return Lbry.channel_update(updateParams) .then((result: ChannelUpdateResponse) => { const channelClaim = result.outputs[0]; dispatch({ type: ACTIONS.UPDATE_CHANNEL_COMPLETED, data: { channelClaim }, }); dispatch({ type: ACTIONS.UPDATE_PENDING_CLAIMS, data: { claims: [channelClaim], }, }); dispatch(doCheckPendingClaims(cb)); return Boolean(result.outputs[0]); }) .then() .catch((error) => { dispatch({ type: ACTIONS.UPDATE_CHANNEL_FAILED, data: error, }); }); }; } export function doImportChannel(certificate: string) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.IMPORT_CHANNEL_STARTED, }); return Lbry.channel_import({ channel_data: certificate }) .then(() => { dispatch({ type: ACTIONS.IMPORT_CHANNEL_COMPLETED, }); }) .catch((error) => { dispatch({ type: ACTIONS.IMPORT_CHANNEL_FAILED, data: error, }); }); }; } export function doFetchChannelListMine(page: number = 1, pageSize: number = 99999, resolve: boolean = true) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.FETCH_CHANNEL_LIST_STARTED, }); const callback = (response: ChannelListResponse) => { dispatch({ type: ACTIONS.FETCH_CHANNEL_LIST_COMPLETED, data: { claims: response.items }, }); }; const failure = (error) => { dispatch({ type: ACTIONS.FETCH_CHANNEL_LIST_FAILED, data: error, }); }; Lbry.channel_list({ page, page_size: pageSize, resolve }).then(callback, failure); }; } 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, }) ); }; const failure = (error) => { dispatch({ type: ACTIONS.FETCH_COLLECTION_LIST_FAILED, data: error, }); }; Lbry.collection_list({ page, page_size: pageSize, resolve_claims: 1, resolve: true }).then(callback, failure); }; } export function doClaimSearch( options: { page_size?: number, page: number, no_totals?: boolean, any_tags?: Array, claim_ids?: Array, channel_ids?: Array, not_channel_ids?: Array, not_tags?: Array, order_by?: Array, release_time?: string, has_source?: boolean, has_no_souce?: boolean, } = { no_totals: true, page_size: 10, page: 1, } ) { const query = createNormalizedClaimSearchKey(options); return async (dispatch: Dispatch) => { dispatch({ type: ACTIONS.CLAIM_SEARCH_STARTED, data: { query: query }, }); const success = (data: ClaimSearchResponse) => { const resolveInfo = {}; const urls = []; data.items.forEach((stream: Claim) => { resolveInfo[stream.canonical_url] = { stream }; urls.push(stream.canonical_url); }); dispatch({ type: ACTIONS.CLAIM_SEARCH_COMPLETED, data: { query, resolveInfo, urls, append: options.page && options.page !== 1, pageSize: options.page_size, }, }); return resolveInfo; }; const failure = (err) => { dispatch({ type: ACTIONS.CLAIM_SEARCH_FAILED, data: { query }, error: err, }); return false; }; return await Lbry.claim_search({ ...options, include_purchase_receipt: true, }).then(success, failure); }; } export function doRepost(options: StreamRepostOptions) { return (dispatch: Dispatch): Promise => { return new Promise((resolve) => { dispatch({ type: ACTIONS.CLAIM_REPOST_STARTED, }); function success(response) { const repostClaim = response.outputs[0]; dispatch({ type: ACTIONS.CLAIM_REPOST_COMPLETED, data: { originalClaimId: options.claim_id, repostClaim, }, }); dispatch({ type: ACTIONS.UPDATE_PENDING_CLAIMS, data: { claims: [repostClaim], }, }); dispatch(doFetchClaimListMine(1, 10)); resolve(repostClaim); } function failure(error) { dispatch({ type: ACTIONS.CLAIM_REPOST_FAILED, data: { error: error.message, }, }); } Lbry.stream_repost(options).then(success, failure); }); }; } 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): Promise => { // $FlowFixMe const params: { name: string, bid: string, channel_id?: string, blocking?: true, title?: string, thumbnail_url?: string, description?: string, tags?: Array, languages?: Array, claims: Array, } = { name: options.name, bid: creditsToString(options.bid), title: options.title, thumbnail_url: options.thumbnail_url, description: options.description, tags: [], languages: options.languages || [], locations: [], blocking: true, claims: options.claims, }; if (options.tags) { params['tags'] = options.tags.map((tag) => tag.name); } if (options.channel_id) { params['channel_id'] = options.channel_id; } 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, data: { claimId: collectionClaim.claim_id }, }, // move unpublished collection to pending collection with new publish id // recent publish won't resolve this second. handle it in checkPending { type: ACTIONS.UPDATE_PENDING_CLAIMS, data: { claims: [collectionClaim], }, } ) ); dispatch({ type: ACTIONS.COLLECTION_PENDING, data: { localId: localId, claimId: collectionClaim.claim_id }, }); dispatch(doCheckPendingClaims()); dispatch(doFetchCollectionListMine(1, 10)); return resolve(collectionClaim); } function failure(error) { dispatch({ type: ACTIONS.COLLECTION_PUBLISH_FAILED, data: { error: error.message, }, }); } return Lbry.collection_create(params).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, claims?: Array, channel_id?: string, }, isBackgroundUpdate?: boolean ) { return (dispatch: Dispatch, getState: GetState): Promise => { // TODO: implement one click update const updateParams: { bid?: string, blocking?: true, title?: string, thumbnail_url?: string, channel_id?: string, description?: string, claim_id: string, tags?: Array, languages?: Array, claims?: Array, clear_claims: boolean, replace?: boolean, } = isBackgroundUpdate ? { blocking: true, claim_id: options.claim_id, clear_claims: true, } : { bid: creditsToString(options.bid), title: options.title, thumbnail_url: options.thumbnail_url, description: options.description, tags: [], languages: options.languages || [], locations: [], blocking: true, claim_id: options.claim_id, clear_claims: true, replace: true, }; if (isBackgroundUpdate && updateParams.claim_id) { const state = getState(); updateParams['claims'] = makeSelectClaimIdsForCollectionId(updateParams.claim_id)(state); } else if (options.claims) { updateParams['claims'] = options.claims; } if (options.tags) { updateParams['tags'] = options.tags.map((tag) => tag.name); } if (options.channel_id) { updateParams['channel_id'] = options.channel_id; } 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.COLLECTION_PENDING, data: { claimId: collectionClaim.claim_id }, }); dispatch({ type: ACTIONS.UPDATE_PENDING_CLAIMS, data: { claims: [collectionClaim], }, }); dispatch(doCheckPendingClaims()); return resolve(collectionClaim); } function failure(error) { dispatch({ type: ACTIONS.COLLECTION_PUBLISH_UPDATE_FAILED, data: { error: error.message, }, }); } return Lbry.collection_update(updateParams).then(success, failure); }); }; } export function doCheckPublishNameAvailability(name: string) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.CHECK_PUBLISH_NAME_STARTED, }); return Lbry.claim_list({ name: name }).then((result) => { dispatch({ type: ACTIONS.CHECK_PUBLISH_NAME_COMPLETED, }); if (result.items.length) { dispatch({ type: ACTIONS.FETCH_CLAIM_LIST_MINE_COMPLETED, data: { result, resolve: false, }, }); } return !(result && result.items && result.items.length); }); }; } export function doClearRepostError() { return { type: ACTIONS.CLEAR_REPOST_ERROR, }; } export function doPurchaseList(page: number = 1, pageSize: number = PAGE_SIZE) { return (dispatch: Dispatch) => { dispatch({ type: ACTIONS.PURCHASE_LIST_STARTED, }); const success = (result: PurchaseListResponse) => { return dispatch({ type: ACTIONS.PURCHASE_LIST_COMPLETED, data: { result, }, }); }; const failure = (error) => { dispatch({ type: ACTIONS.PURCHASE_LIST_FAILED, data: { error: error.message, }, }); }; Lbry.purchase_list({ page: page, page_size: pageSize, resolve: true, }).then(success, failure); }; } export const doCheckPendingClaims = (onChannelConfirmed: Function) => (dispatch: Dispatch, getState: GetState) => { if (onChannelConfirmed) { onChannelConfirmCallback = onChannelConfirmed; } clearInterval(checkPendingInterval); const checkTxoList = () => { const state = getState(); const pendingById = Object.assign({}, selectPendingClaimsById(state)); const pendingTxos = (Object.values(pendingById): any).map((p) => p.txid); // use collections if (pendingTxos.length) { Lbry.txo_list({ txid: pendingTxos }) .then((result) => { const txos = result.items; const idsToConfirm = []; txos.forEach((txo) => { if (txo.claim_id && txo.confirmations > 0) { idsToConfirm.push(txo.claim_id); delete pendingById[txo.claim_id]; } }); return { idsToConfirm, pendingById }; }) .then((results) => { const { idsToConfirm, pendingById } = results; if (idsToConfirm.length) { return Lbry.claim_list({ claim_id: idsToConfirm, resolve: true }).then((results) => { const claims = results.items; const collectionIds = claims.filter((c) => c.value_type === 'collection').map((c) => c.claim_id); dispatch({ type: ACTIONS.UPDATE_CONFIRMED_CLAIMS, data: { claims: claims, pending: pendingById, }, }); if (collectionIds.length) { dispatch( doFetchItemsInCollections({ collectionIds, }) ); } const channelClaims = claims.filter((claim) => claim.value_type === 'channel'); if (channelClaims.length && onChannelConfirmCallback) { channelClaims.forEach((claim) => onChannelConfirmCallback(claim)); } if (Object.keys(pendingById).length === 0) { clearInterval(checkPendingInterval); } }); } }); } else { clearInterval(checkPendingInterval); } }; // do something with onConfirmed (typically get blocklist for channel) checkPendingInterval = setInterval(() => { checkTxoList(); }, 30000); };