diff --git a/CHANGELOG.md b/CHANGELOG.md index 080d50db1..4a0ad0c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Web UI version numbers should always match the corresponding version of LBRY App * You can now make API calls directly on the lbry module, e.g. lbry.peer_list() * New-style API calls return promises instead of using callbacks * Wherever possible, use outpoints for unique IDs instead of names or SD hashes + * New publishes now display immediately in My Files, even before they hit the lbrynet file manager. ### Changed * Update process now easier and more reliable diff --git a/ui/js/component/file-actions.js b/ui/js/component/file-actions.js index b9d831e69..c8331e261 100644 --- a/ui/js/component/file-actions.js +++ b/ui/js/component/file-actions.js @@ -179,7 +179,7 @@ let FileActionsRow = React.createClass({ let linkBlock; if (this.state.fileInfo === false && !this.state.attemptingDownload) { linkBlock = ; - } else if (this.state.attemptingDownload || !this.state.fileInfo.completed) { + } else if (this.state.attemptingDownload || (!this.state.fileInfo.completed && !this.state.fileInfo.isMine)) { const progress = this.state.fileInfo ? this.state.fileInfo.written_bytes / this.state.fileInfo.total_bytes * 100 : 0, label = this.state.fileInfo ? progress.toFixed(0) + '% complete' : 'Connecting...', diff --git a/ui/js/lbry.js b/ui/js/lbry.js index 4f5f6cbec..aeb91e12f 100644 --- a/ui/js/lbry.js +++ b/ui/js/lbry.js @@ -1,15 +1,103 @@ import lighthouse from './lighthouse.js'; import jsonrpc from './jsonrpc.js'; +import {getLocal, setLocal} from './utils.js'; const {remote} = require('electron'); const menu = remote.require('./menu/main-menu'); +/** + * Records a publish attempt in local storage. Returns a dictionary with all the data needed to + * needed to make a dummy claim or file info object. + */ +function savePendingPublish(name) { + const pendingPublishes = getLocal('pendingPublishes') || []; + const newPendingPublish = { + claim_id: 'pending_claim_' + name, + txid: 'pending_' + name, + nout: 0, + outpoint: 'pending_' + name + ':0', + name: name, + time: Date.now(), + }; + setLocal('pendingPublishes', [...pendingPublishes, newPendingPublish]); + return newPendingPublish; +} + +function removePendingPublish({name, outpoint}) { + setLocal('pendingPublishes', getPendingPublishes().filter( + (pub) => pub.name != name && pub.outpoint != outpoint + )); +} + +/** + * Gets the current list of pending publish attempts. Filters out any that have timed out and + * removes them from the list. + */ +function getPendingPublishes() { + const pendingPublishes = getLocal('pendingPublishes') || []; + + const newPendingPublishes = []; + for (let pendingPublish of pendingPublishes) { + if (Date.now() - pendingPublish.time <= lbry.pendingPublishTimeout) { + newPendingPublishes.push(pendingPublish); + } + } + setLocal('pendingPublishes', newPendingPublishes); + return newPendingPublishes +} + +/** + * Gets a pending publish attempt by its name or (fake) outpoint. If none is found (or one is found + * but it has timed out), returns null. + */ +function getPendingPublish({name, outpoint}) { + const pendingPublishes = getPendingPublishes(); + const pendingPublishIndex = pendingPublishes.findIndex( + ({name: itemName, outpoint: itemOutpoint}) => itemName == name || itemOutpoint == outpoint + ); + const pendingPublish = pendingPublishes[pendingPublishIndex]; + + if (pendingPublishIndex == -1) { + return null; + } else if (Date.now() - pendingPublish.time > lbry.pendingPublishTimeout) { + // Pending publish timed out, so remove it from the stored list and don't match + + const newPendingPublishes = pendingPublishes.slice(); + newPendingPublishes.splice(pendingPublishIndex, 1); + setLocal('pendingPublishes', newPendingPublishes); + return null; + } else { + return pendingPublish; + } +} + +function pendingPublishToDummyClaim({name, outpoint, claim_id, txid, nout}) { + return { + name: name, + outpoint: outpoint, + claim_id: claim_id, + txid: txid, + nout: nout, + }; +} + +function pendingPublishToDummyFileInfo({name, outpoint, claim_id}) { + return { + name: name, + outpoint: outpoint, + claim_id: claim_id, + metadata: "Attempting publication", + }; +} + + let lbry = { isConnected: false, rootPath: '.', daemonConnectionString: 'http://localhost:5279/lbryapi', webUiUri: 'http://localhost:5279', peerListTimeout: 6000, + pendingPublishTimeout: 20 * 60 * 1000, colors: { primary: '#155B4A' }, @@ -251,18 +339,49 @@ lbry.getFileInfoWhenListed = function(name, callback, timeoutCallback, tryNum=0) }, () => scheduleNextCheckOrTimeout()); } +/** + * Publishes a file. The optional fileListedCallback is called when the file becomes available in + * lbry.file_list() during the publish process. + * + * This currently includes a work-around to cache the file in local storage so that the pending + * publish can appear in the UI immediately. + */ lbry.publish = function(params, fileListedCallback, publishedCallback, errorCallback) { - // Publishes a file. - // The optional fileListedCallback is called when the file becomes available in - // lbry.getFilesInfo() during the publish process. + lbry.call('publish', params, (result) => { + if (returnedPending) { + return; + } - // Use ES6 named arguments instead of directly passing param dict? - lbry.call('publish', params, publishedCallback, errorCallback); - if (fileListedCallback) { - lbry.getFileInfoWhenListed(params.name, function(fileInfo) { - fileListedCallback(fileInfo); - }); - } + clearTimeout(returnPendingTimeout); + publishedCallback(result); + }, (err) => { + if (returnedPending) { + return; + } + + clearTimeout(returnPendingTimeout); + errorCallback(err); + }); + + let returnedPending = false; + // Give a short grace period in case publish() returns right away or (more likely) gives an error + const returnPendingTimeout = setTimeout(() => { + returnedPending = true; + + if (publishedCallback) { + savePendingPublish(params.name); + publishedCallback(true); + } + + if (fileListedCallback) { + savePendingPublish(params.name); + fileListedCallback(true); + } + }, 2000); + + //lbry.getFileInfoWhenListed(params.name, function(fileInfo) { + // fileListedCallback(fileInfo); + //}); } lbry.getVersionInfo = function(callback) { @@ -503,6 +622,60 @@ lbry.showMenuIfNeeded = function() { sessionStorage.setItem('menuShown', chosenMenu); }; +/** + * Wrappers for API methods to simulate missing or future behavior. Unlike the old-style stubs, + * these are designed to be transparent wrappers around the corresponding API methods. + */ + +/** + * Returns results from the file_list API method, plus dummy entries for pending publishes. + * (If a real publish with the same name is found, the pending publish will be ignored and removed.) + */ +lbry.file_list = function(params={}) { + return new Promise((resolve, reject) => { + const {name, outpoint} = params; + + /** + * If we're searching by outpoint, check first to see if there's a matching pending publish. + * Pending publishes use their own faux outpoints that are always unique, so we don't need + * to check if there's a real file. + */ + if (outpoint !== undefined) { + const pendingPublish = getPendingPublish({outpoint}); + if (pendingPublish) { + resolve([pendingPublishToDummyFileInfo(pendingPublish)]); + return; + } + } + + lbry.call('file_list', params, (fileInfos) => { + // Remove any pending publications that are now listed in the file manager + + const pendingPublishes = getPendingPublishes(); + for (let {name: itemName} of fileInfos) { + if (pendingPublishes.find(() => name == itemName)) { + removePendingPublish({name: name}); + } + } + const dummyFileInfos = getPendingPublishes().map(pendingPublishToDummyFileInfo); + resolve([...fileInfos, ...dummyFileInfos]); + }, reject, reject); + }); +} + +lbry.claim_list_mine = function(params={}) { + return new Promise((resolve, reject) => { + lbry.call('claim_list_mine', params, (claims) => { + // Filter out pending publishes when the name is already in the file manager + const dummyClaims = getPendingPublishes().filter( + (pub) => !claims.find(({name}) => name == pub.name) + ).map(pendingPublishToDummyClaim); + + resolve([...claims, ...dummyClaims]); + }, reject, reject); + }); +} + lbry = new Proxy(lbry, { get: function(target, name) { if (name in target) { diff --git a/ui/js/utils.js b/ui/js/utils.js new file mode 100644 index 000000000..5b5cf246a --- /dev/null +++ b/ui/js/utils.js @@ -0,0 +1,15 @@ +/** + * Thin wrapper around localStorage.getItem(). Parses JSON and returns undefined if the value + * is not set yet. + */ +export function getLocal(key) { + const itemRaw = localStorage.getItem(key); + return itemRaw === null ? undefined : JSON.parse(itemRaw); +} + +/** + * Thin wrapper around localStorage.setItem(). Converts value to JSON. + */ +export function setLocal(key, value) { + localStorage.setItem(key, JSON.stringify(value)); +}