diff --git a/.appveyor.yml b/.appveyor.yml index 52e3c6879..21e80e683 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,8 +1,14 @@ # Test against the latest version of this Node.js version environment: - nodejs_version: "7" + nodejs_version: 7 GH_TOKEN: secure: LiI5jyuHUw6XbH4kC3gP1HX4P/v4rwD/gCNtaFhQu2AvJz1/1wALkp5ECnIxRySN + key_pass: + secure: u6DydPcdrUJlxGL9uc7yQRYG8+5rY6aAEE9nfCSzFyNzZlX9NniOp8Uh5ZKQqX7bGEngLI6ipbLfiJvn0XFnhbn2iTkOuMqOXVJVOehvwlQ= + pfx_key: + secure: 1mwqyRy7hDqDjDK+TIAoaXyXzpNgwruFNA6TPkinUcVM7A+NLD33RQLnfnwVy+R5ovD2pUfhQ6+N0Fqebv6tZh436LIEsock+6IOdpgFwrg= + # find with: Get-Childitem –Path "C:\Program Files (x86)\Microsoft SDKs\Windows\" -Include *signtool* -File -Recurse -ErrorAction SilentlyContinue + SIGNTOOL_PATH: C:\Program Files (x86)\Microsoft SDKs\Windows\v7.1A\Bin\signtool.exe skip_branch_with_pr: true diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4b1252147..cd4cb547a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.2rc15 +current_version = 0.10.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)((?P[a-z]+)(?P\d+))? diff --git a/.gitignore b/.gitignore index ccfec0983..d1b68b9b7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist /app/node_modules /build/venv /lbry-app-venv +/lbry-venv /daemon/build /daemon/venv /daemon/requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0ad0c72..e5c7dcda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,25 +8,56 @@ Web UI version numbers should always match the corresponding version of LBRY App ## [Unreleased] ### Added - * The app is much more responsive switching pages. It no longer reloads the entire page and all assets on each page change. - * lbry.js now offers a subscription model for wallet balance similar to file info. - * Fixed file info subscribes not being unsubscribed in unmount. - * Fixed drawer not highlighting selected page. - * 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 - * Cleaned up shutdown logic + * * ### Fixed - * Fix Watch page and progress bars for new API changes + * Error modals now display full screen properly + * + +### Deprecated * * +### Removed + * + * + +## [0.10.0] - 2017-05-04 + +### Added + * The UI has been overhauled to use an omnibar and drop the sidebar. + * The app is much more responsive switching pages. It no longer reloads the entire page and all assets on each page change. + * lbry.js now offers a subscription model for wallet balance similar to file info. + * Fixed file info subscribes not being unsubscribed in unmount. + * Fixed drawer not highlighting selected page. + * 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. + * New welcome flow for new users + * Redesigned UI for Discover + * Handle more of price calculations at the daemon layer to improve page load time + * Add special support for building channel claims in lbryuri module + * Enable windows code signing of binary + + +### Changed + * Update process now easier and more reliable + * Updated search to be compatible with new Lighthouse servers + * Cleaned up shutdown logic + * Support lbry v0.10 API signatures + + +### Fixed + * Fix Watch page and progress bars for new API changes + + + ## [0.9.0rc15] - 2017-03-09 ### Added * A way to access the Developer Settings panel in Electron (Ctrl-Shift and click logo) diff --git a/README.md b/README.md index e93d9f630..7403db3bf 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,4 @@ to create distributable packages, which is run by calling: ### Development on Windows This project has currently only been worked on in Linux and macOS. If you are on Windows, you can -checkout out the build steps in [appveyor.yml](https://github.com/lbryio/lbry-app/blob/master/appveyor.yml) and probably figure out something from there. +checkout out the build steps in [appveyor.yml](https://github.com/lbryio/lbry-app/blob/master/.appveyor.yml) and probably figure out something from there. diff --git a/app/main.js b/app/main.js index 9c4a72a7e..125423da4 100644 --- a/app/main.js +++ b/app/main.js @@ -1,11 +1,18 @@ const {app, BrowserWindow, ipcMain} = require('electron'); +const url = require('url'); const path = require('path'); const jayson = require('jayson'); +const semver = require('semver'); +const https = require('https'); // tree-kill has better cross-platform handling of // killing a process. child-process.kill was unreliable const kill = require('tree-kill'); const child_process = require('child_process'); const assert = require('assert'); +const {version: localVersion} = require(app.getAppPath() + '/package.json'); + +const VERSION_CHECK_INTERVAL = 30 * 60 * 1000; +const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/lbryio/lbry-app/releases/latest'; let client = jayson.client.http('http://localhost:5279/lbryapi'); @@ -17,12 +24,58 @@ let daemonSubprocess; // This is set to true right before we try to shut the daemon subprocess -- // if it dies when we didn't ask it to shut down, we want to alert the user. -let daemonSubprocessKillRequested = false; +let daemonStopRequested = false; // When a quit is attempted, we cancel the quit, do some preparations, then // this is set to true and app.quit() is called again to quit for real. let readyToQuit = false; +function checkForNewVersion(callback) { + function formatRc(ver) { + // Adds dash if needed to make RC suffix semver friendly + return ver.replace(/([^-])rc/, '$1-rc'); + } + + let result = ''; + const opts = { + headers: { + 'User-Agent': `LBRY/${localVersion}`, + } + }; + const req = https.get(Object.assign(opts, url.parse(LATEST_RELEASE_API_URL)), (res) => { + res.on('data', (data) => { + result += data; + }); + res.on('end', () => { + console.log('Local version:', localVersion); + const tagName = JSON.parse(result).tag_name; + const [_, remoteVersion] = tagName.match(/^v([\d.]+(?:-?rc\d+)?)$/); + if (!remoteVersion) { + console.log('Malformed remote version string:', tagName); + if (win) { + win.webContents.send('version-info-received', null); + } + } else { + console.log('Remote version:', remoteVersion); + const upgradeAvailable = semver.gt(formatRc(remoteVersion), formatRc(localVersion)); + console.log(upgradeAvailable ? 'Upgrade available' : 'No upgrade available'); + if (win) { + win.webContents.send('version-info-received', {remoteVersion, localVersion, upgradeAvailable}); + } + } + }) + }); + + req.on('error', (err) => { + console.log('Failed to get current version from GitHub. Error:', err); + if (win) { + win.webContents.send('version-info-received', null); + } + }); +} + +ipcMain.on('version-info-requested', checkForNewVersion); + /* * Replacement for Electron's shell.openItem. The Electron version doesn't * reliably work from the main process, and we need to be able to run it @@ -62,9 +115,9 @@ function getPidsForProcessName(name) { } function createWindow () { - win = new BrowserWindow({backgroundColor: '#155B4A'}) //$color-primary + win = new BrowserWindow({backgroundColor: '#155B4A', minWidth: 800, minHeight: 600 }) //$color-primary win.maximize() - //win.webContents.openDevTools() + // win.webContents.openDevTools(); win.loadURL(`file://${__dirname}/dist/index.html`) win.on('closed', () => { win = null @@ -74,8 +127,8 @@ function createWindow () { function handleDaemonSubprocessExited() { console.log('The daemon has exited.'); daemonSubprocess = null; - if (!daemonSubprocessKillRequested) { - // We didn't stop the daemon subprocess on purpose, so display a + if (!daemonStopRequested) { + // We didn't request to stop the daemon, so display a // warning and schedule a quit. // // TODO: maybe it would be better to restart the daemon? @@ -107,7 +160,6 @@ function launchDaemon() { daemonSubprocess.stdout.on('data', (buf) => {console.log(String(buf).trim());}); daemonSubprocess.stderr.on('data', (buf) => {console.log(String(buf).trim());}); daemonSubprocess.on('exit', handleDaemonSubprocessExited); - console.log('lbrynet daemon has launched') } /* @@ -209,31 +261,27 @@ app.on('activate', () => { // When a quit is attempted, this is called. It attempts to shutdown the daemon, // then calls quitNow() to quit for real. function shutdownDaemonAndQuit(evenIfNotStartedByApp = false) { - if (daemonSubprocess) { - console.log('Killing lbrynet-daemon process'); - daemonSubprocessKillRequested = true; - kill(daemonSubprocess.pid, undefined, (err) => { - console.log('Killed lbrynet-daemon process'); - quitNow(); - }); - } else if (evenIfNotStartedByApp) { - console.log('Stopping lbrynet-daemon, even though app did not start it'); + function doShutdown() { + console.log('Asking daemon to shut down down'); + daemonStopRequested = true; client.request('daemon_stop', [], (err, res) => { if (err) { - // We could get an error because the daemon is already stopped (good) - // or because it's running but not responding properly (bad). - // So try to force kill any daemons that are still running. - - console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}`); - forceKillAllDaemonsAndQuit(); + console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}\n`); + console.log('You will need to manually kill the daemon.'); } else { console.log('Successfully stopped daemon via RPC call.') quitNow(); } }); - } else { + } + + if (daemonSubprocess) { + doShutdown(); + } else if (!evenIfNotStartedByApp) { console.log('Not killing lbrynet-daemon because app did not start it'); quitNow(); + } else { + doShutdown(); } // Is it safe to start the installer before the daemon finishes running? diff --git a/app/menu/main-menu.js b/app/menu/main-menu.js index 585435730..32fc5168f 100644 --- a/app/menu/main-menu.js +++ b/app/menu/main-menu.js @@ -24,6 +24,9 @@ const baseTemplate = [ { role: 'paste', }, + { + role: 'selectall', + }, ] }, { diff --git a/app/package.json b/app/package.json index 1f0bcda89..bf04050d9 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "LBRY", - "version": "0.9.2rc15", + "version": "0.10.0", "main": "main.js", "description": "LBRY is a fully decentralized, open-source protocol facilitating the discovery, access, and (sometimes) purchase of data.", "author": { @@ -12,6 +12,7 @@ "install": "^0.8.7", "jayson": "^2.0.2", "npm": "^4.2.0", + "semver": "^5.3.0", "tree-kill": "^1.1.0" } } diff --git a/build/DAEMON_URL b/build/DAEMON_URL index ea9f7e7d9..44a331d24 100644 --- a/build/DAEMON_URL +++ b/build/DAEMON_URL @@ -1 +1 @@ -https://github.com/lbryio/lbry/releases/download/v0.9.2rc3/lbrynet-daemon-v0.9.2rc3-OSNAME.zip +https://github.com/lbryio/lbry/releases/download/v0.10.3rc1/lbrynet-daemon-v0.10.3rc1-OSNAME.zip diff --git a/build/build.ps1 b/build/build.ps1 index 3df3d9b7c..431b418c4 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -31,4 +31,10 @@ $binary_name = Get-ChildItem -Path dist -Filter '*.exe' -Name $new_name = $binary_name -replace '^LBRY Setup (.*)\.exe$', 'LBRY_$1.exe' Rename-Item -Path "dist\$binary_name" -NewName $new_name dir dist # verify that binary was built/named correctly + +# sign binary +nuget install secure-file -ExcludeVersion +secure-file\tools\secure-file -decrypt build\lbry2.pfx.enc -secret "$env:pfx_key" +& ${env:SIGNTOOL_PATH} sign /f build\lbry2.pfx /p "$env:key_pass" /tr http://tsa.starfieldtech.com /td SHA256 /fd SHA256 dist\*.exe + python build\release_on_tag.py \ No newline at end of file diff --git a/build/build.sh b/build/build.sh index 60eb60759..1b3340054 100755 --- a/build/build.sh +++ b/build/build.sh @@ -72,17 +72,15 @@ npm install # daemon and cli # #################### -if [ "$FULL_BUILD" == "true" ]; then - if $OSX; then - OSNAME="macos" - else - OSNAME="linux" - fi - DAEMON_URL="$(cat "$BUILD_DIR/DAEMON_URL" | sed "s/OSNAME/${OSNAME}/")" - wget --quiet "$DAEMON_URL" -O "$BUILD_DIR/daemon.zip" - unzip "$BUILD_DIR/daemon.zip" -d "$ROOT/app/dist/" - rm "$BUILD_DIR/daemon.zip" +if $OSX; then + OSNAME="macos" +else + OSNAME="linux" fi +DAEMON_URL="$(cat "$BUILD_DIR/DAEMON_URL" | sed "s/OSNAME/${OSNAME}/")" +wget --quiet "$DAEMON_URL" -O "$BUILD_DIR/daemon.zip" +unzip "$BUILD_DIR/daemon.zip" -d "$ROOT/app/dist/" +rm "$BUILD_DIR/daemon.zip" ################### # Build the app # @@ -116,4 +114,4 @@ if [ "$FULL_BUILD" == "true" ]; then echo 'Build and packaging complete.' else echo 'Build complete. Run `./node_modules/.bin/electron app` to launch the app' -fi \ No newline at end of file +fi diff --git a/build/changelog.py b/build/changelog.py index eb03b682f..322fc30a7 100644 --- a/build/changelog.py +++ b/build/changelog.py @@ -24,6 +24,14 @@ TEMPLATE = """### Added * * +### Deprecated + * + * + +### Removed + * + * + """ @@ -96,6 +104,7 @@ class Changelog(object): output.append('### {}'.format(section)) for entry in sections[section]: output.append(' * {}'.format(entry)) + output.append("\n") return output def get_unreleased(self): @@ -106,7 +115,7 @@ class Changelog(object): return today = datetime.datetime.today() - header = '## [{}] - {}\n'.format(version, today.strftime('%Y-%m-%d')) + header = "## [{}] - {}\n\n".format(version, today.strftime('%Y-%m-%d')) changelog_data = ( ''.join(self.start) + diff --git a/build/lbry2.pfx.enc b/build/lbry2.pfx.enc new file mode 100644 index 000000000..46e52260a Binary files /dev/null and b/build/lbry2.pfx.enc differ diff --git a/build/release.py b/build/release.py index 619ad7df9..1a8392160 100644 --- a/build/release.py +++ b/build/release.py @@ -6,6 +6,7 @@ import argparse import contextlib import os import re +import requests import subprocess import sys @@ -15,6 +16,7 @@ import github import changelog ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +DAEMON_URL_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'DAEMON_URL') def main(): @@ -36,6 +38,11 @@ def main(): print 'Current version: {}'.format(repo.current_version) print 'New version: {}'.format(repo.new_version) + with open(DAEMON_URL_FILE, 'r') as f: + daemon_url_template = f.read().strip() + daemon_version = re.search('/(?Pv[^/]+)', daemon_url_template) + print 'Daemon version: {} ({})'.format( + daemon_version.group('version'), daemon_url_template) if not args.confirm and not confirm(): print "Aborting" @@ -156,7 +163,10 @@ in the future""" def confirm(): - return raw_input('Is this what you want? [y/N] ').strip().lower() == 'y' + try: + return raw_input('Is this what you want? [y/N] ').strip().lower() == 'y' + except KeyboardInterrupt: + return False def run_sanity_checks(repo, branch): @@ -175,6 +185,39 @@ def run_sanity_checks(repo, branch): 'pip install -U git+https://github.com/lbryio/bumpversion.git' ) sys.exit(1) + if not check_daemon_urls(): + sys.exit(1) + + +def check_daemon_urls(): + success = True + with open(DAEMON_URL_FILE, 'r') as f: + daemon_url_template = f.read().strip() + if "OSNAME" not in daemon_url_template: + print "Daemon URL must include the string 'OSNAME'" + return False + for osname in ('linux', 'macos', 'windows'): + if not check_url(daemon_url_template.replace('OSNAME', osname)): + success = False + print "Daemon URL for " + osname + " does not work" + return success + + +def check_url(url): + url = url.strip() + r = requests.head(url) + if r.status_code >= 400: + return False + elif r.status_code >= 300: + new_location = r.headers.get('Location').strip() + if new_location == url: + # self-loop + return False + if "github-cloud.s3.amazonaws.com/releases" in new_location: + # HEAD doesnt work on s3 links, so assume its good + return True + return check_url(new_location) + return True def is_custom_bumpversion_version(): diff --git a/build/set_version.py b/build/set_version.py index 80b777d89..313cf6c93 100644 --- a/build/set_version.py +++ b/build/set_version.py @@ -1,51 +1,20 @@ """Set the package version to the output of `git describe`""" -import argparse -import json +from __future__ import print_function + import os.path -import re -import subprocess import sys +import fileinput def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--version', help="defaults to the output of `git describe`") - args = parser.parse_args() - if args.version: - version = args.version - else: - tag = subprocess.check_output(['git', 'describe']).strip() - try: - version = get_version_from_tag(tag) - except InvalidVersionTag: - # this should be an error but its easier to handle here - # than in the calling scripts. - print 'Tag cannot be converted to a version, Exitting' - return - set_version(version) - - -class InvalidVersionTag(Exception): - pass - - -def get_version_from_tag(tag): - match = re.match('v([\d.]+)', tag) - if match: - return match.group(1) - else: - raise InvalidVersionTag('Failed to parse version from tag {}'.format(tag)) - - -def set_version(version): - root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - package_file = os.path.join(root_dir, 'app', 'package.json') - with open(package_file) as fp: - package_data = json.load(fp) - package_data['version'] = version - with open(package_file, 'w') as fp: - json.dump(package_data, fp, indent=2, separators=(',', ': ')) + filename = os.path.abspath( + os.path.join(os.path.abspath(__file__), '..', '..', 'ui', 'js', 'lbryio.js')) + for line in fileinput.input(filename, inplace=True): + if line.startswith(' enabled: false'): + print(' enabled: true') + else: + print(line, end='') if __name__ == '__main__': diff --git a/package.json b/package.json index f342a1e52..45142bbaa 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,6 @@ "devDependencies": { "electron": "^1.4.15", "electron-builder": "^11.7.0" - } + }, + "dependencies": {} } diff --git a/ui/js/app.js b/ui/js/app.js index 76ee648bd..bdef281a0 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -7,16 +7,16 @@ import HelpPage from './page/help.js'; import WatchPage from './page/watch.js'; import ReportPage from './page/report.js'; import StartPage from './page/start.js'; -import ClaimCodePage from './page/claim_code.js'; -import ReferralPage from './page/referral.js'; +import RewardsPage from './page/rewards.js'; +import RewardPage from './page/reward.js'; import WalletPage from './page/wallet.js'; -import DetailPage from './page/show.js'; +import ShowPage from './page/show.js'; import PublishPage from './page/publish.js'; +import SearchPage from './page/search.js'; import DiscoverPage from './page/discover.js'; -import SplashScreen from './component/splash.js'; import DeveloperPage from './page/developer.js'; +import lbryuri from './lbryuri.js'; import {FileListDownloaded, FileListPublished} from './page/file-list.js'; -import Drawer from './component/drawer.js'; import Header from './component/header.js'; import {Modal, ExpandableModal} from './component/modal.js'; import {Link} from './component/link.js'; @@ -38,17 +38,12 @@ var App = React.createClass({ message: 'Error message', data: 'Error data', }, + _fullScreenPages: ['watch'], + _storeHistoryOfNextRender: false, _upgradeDownloadItem: null, _isMounted: false, _version: null, - - // Temporary workaround since electron-dl throws errors when you try to get the filename - getDefaultProps: function() { - return { - address: window.location.search - }; - }, getUpdateUrl: function() { switch (process.platform) { case 'darwin': @@ -80,15 +75,13 @@ var App = React.createClass({ let [isMatch, viewingPage, pageArgs] = address.match(/\??([^=]*)(?:=(.*))?/); return { viewingPage: viewingPage, - pageArgs: pageArgs === undefined ? null : pageArgs + pageArgs: pageArgs === undefined ? null : decodeURIComponent(pageArgs) }; }, getInitialState: function() { - var match, param, val, viewingPage, pageArgs, - drawerOpenRaw = sessionStorage.getItem('drawerOpen'); - - return Object.assign(this.getViewingPageAndArgs(this.props.address), { - drawerOpen: drawerOpenRaw !== null ? JSON.parse(drawerOpenRaw) : true, + return Object.assign(this.getViewingPageAndArgs(window.location.search), { + viewingPage: 'discover', + appUrl: null, errorInfo: null, modal: null, downloadProgress: null, @@ -96,6 +89,8 @@ var App = React.createClass({ }); }, componentWillMount: function() { + window.addEventListener("popstate", this.onHistoryPop); + document.addEventListener('unhandledError', (event) => { this.alertError(event.detail); }); @@ -112,7 +107,10 @@ var App = React.createClass({ if (target.matches('a[href^="?"]')) { event.preventDefault(); if (this._isMounted) { - this.setState(this.getViewingPageAndArgs(target.getAttribute('href'))); + let appUrl = target.getAttribute('href'); + this._storeHistoryOfNextRender = true; + this.setState(Object.assign({}, this.getViewingPageAndArgs(appUrl), { appUrl: appUrl })); + document.body.scrollTop = 0; } } target = target.parentNode; @@ -120,28 +118,16 @@ var App = React.createClass({ }); if (!sessionStorage.getItem('upgradeSkipped')) { - lbry.checkNewVersionAvailable(({isAvailable}) => { - if (!isAvailable) { - return; - } - - lbry.getVersionInfo((versionInfo) => { - this._version = versionInfo.lbrynet_version; + lbry.getVersionInfo().then(({remoteVersion, upgradeAvailable}) => { + if (upgradeAvailable) { + this._version = remoteVersion; this.setState({ modal: 'upgrade', }); - }); + } }); } }, - openDrawer: function() { - sessionStorage.setItem('drawerOpen', true); - this.setState({ drawerOpen: true }); - }, - closeDrawer: function() { - sessionStorage.setItem('drawerOpen', false); - this.setState({ drawerOpen: false }); - }, closeModal: function() { this.setState({ modal: null, @@ -152,6 +138,28 @@ var App = React.createClass({ }, componentWillUnmount: function() { this._isMounted = false; + window.removeEventListener("popstate", this.onHistoryPop); + }, + onHistoryPop: function() { + this.setState(this.getViewingPageAndArgs(location.search)); + }, + onSearch: function(term) { + this._storeHistoryOfNextRender = true; + const isShow = term.startsWith('lbry://'); + this.setState({ + viewingPage: isShow ? "show" : "search", + appUrl: (isShow ? "?show=" : "?search=") + encodeURIComponent(term), + pageArgs: term + }); + }, + onSubmit: function(uri) { + this._storeHistoryOfNextRender = true; + this.setState({ + address: uri, + appUrl: "?show=" + encodeURIComponent(uri), + viewingPage: "show", + pageArgs: uri + }) }, handleUpgradeClicked: function() { // Make a new directory within temp directory so the filename is guaranteed to be available @@ -205,12 +213,6 @@ var App = React.createClass({ modal: null, }); }, - onSearch: function(term) { - this.setState({ - viewingPage: 'discover', - pageArgs: term - }); - }, alertError: function(error) { var errorInfoList = []; for (let key of Object.keys(error)) { @@ -224,81 +226,57 @@ var App = React.createClass({ errorInfo:
    {errorInfoList}
, }); }, - getHeaderLinks: function() - { - switch(this.state.viewingPage) - { - case 'wallet': - case 'send': - case 'receive': - case 'claim': - case 'referral': - return { - '?wallet' : 'Overview', - '?send' : 'Send', - '?receive' : 'Receive', - '?claim' : 'Claim Beta Code', - '?referral' : 'Check Referral Credit', - }; - case 'downloaded': - case 'published': - return { - '?downloaded': 'Downloaded', - '?published': 'Published', - }; - default: - return null; - } - }, - getMainContent: function() + getContentAndAddress: function() { switch(this.state.viewingPage) { + case 'search': + return [this.state.pageArgs ? this.state.pageArgs : "Search", 'icon-search', ]; case 'settings': - return ; + return ["Settings", "icon-gear", ]; case 'help': - return ; - case 'watch': - return ; + return ["Help", "icon-question", ]; case 'report': - return ; + return ['Report an Issue', 'icon-file', ]; case 'downloaded': - return ; + return ["Downloads & Purchases", "icon-folder", ]; case 'published': - return ; + return ["Publishes", "icon-folder", ]; case 'start': - return ; - case 'claim': - return ; - case 'referral': - return ; + return ["Start", "icon-file", ]; + case 'rewards': + return ["Rewards", "icon-bank", ]; case 'wallet': case 'send': case 'receive': - return ; + return [this.state.viewingPage.charAt(0).toUpperCase() + this.state.viewingPage.slice(1), "icon-bank", ] case 'show': - return ; + return [lbryuri.normalize(this.state.pageArgs), "icon-file", ]; case 'publish': - return ; + return ["Publish", "icon-upload", ]; case 'developer': - return ; + return ["Developer", "icon-file", ]; case 'discover': default: - return ; + return ["Home", "icon-home", ]; } }, render: function() { - var mainContent = this.getMainContent(), - headerLinks = this.getHeaderLinks(), - searchQuery = this.state.viewingPage == 'discover' && this.state.pageArgs ? this.state.pageArgs : ''; + let [address, wunderBarIcon, mainContent] = this.getContentAndAddress(); + + lbry.setTitle(address); + + if (this._storeHistoryOfNextRender) { + this._storeHistoryOfNextRender = false; + history.pushState({}, document.title, this.state.appUrl); + } return ( - this.state.viewingPage == 'watch' ? + this._fullScreenPages.includes(this.state.viewingPage) ? mainContent : -
- -
-
+
+
+
{mainContent}

Error

diff --git a/ui/js/component/auth.js b/ui/js/component/auth.js new file mode 100644 index 000000000..dc36367be --- /dev/null +++ b/ui/js/component/auth.js @@ -0,0 +1,301 @@ +import React from "react"; +import lbryio from "../lbryio.js"; +import Modal from "./modal.js"; +import ModalPage from "./modal-page.js"; +import {Link, RewardLink} from "../component/link.js"; +import {FormRow} from "../component/form.js"; +import {CreditAmount, Address} from "../component/common.js"; +import {getLocal, getSession, setSession, setLocal} from '../utils.js'; + + +const SubmitEmailStage = React.createClass({ + getInitialState: function() { + return { + rewardType: null, + email: '', + submitting: false + }; + }, + handleEmailChanged: function(event) { + this.setState({ + email: event.target.value, + }); + }, + onEmailSaved: function(email) { + this.props.setStage("confirm", { email: email }) + }, + handleSubmit: function(event) { + event.preventDefault(); + + this.setState({ + submitting: true, + }); + lbryio.call('user_email', 'new', {email: this.state.email}, 'post').then(() => { + this.onEmailSaved(this.state.email); + }, (error) => { + if (error.xhr && error.xhr.status == 409) { + this.onEmailSaved(this.state.email); + return; + } else if (this._emailRow) { + this._emailRow.showError(error.message) + } + this.setState({ submitting: false }); + }); + }, + render: function() { + return ( +
+
+ { this._emailRow = ref }} type="text" label="Email" placeholder="scrwvwls@lbry.io" + name="email" value={this.state.email} + onChange={this.handleEmailChanged} /> +
+ +
+ +
+ ); + } +}); + +const ConfirmEmailStage = React.createClass({ + getInitialState: function() { + return { + rewardType: null, + code: '', + submitting: false, + errorMessage: null, + }; + }, + handleCodeChanged: function(event) { + this.setState({ + code: event.target.value, + }); + }, + handleSubmit: function(event) { + event.preventDefault(); + this.setState({ + submitting: true, + }); + + const onSubmitError = (error) => { + if (this._codeRow) { + this._codeRow.showError(error.message) + } + this.setState({ submitting: false }); + }; + + lbryio.call('user_email', 'confirm', {verification_token: this.state.code, email: this.props.email}, 'post').then((userEmail) => { + if (userEmail.IsVerified) { + this.props.setStage("welcome") + } else { + onSubmitError(new Error("Your email is still not verified.")) //shouldn't happen? + } + }, onSubmitError); + }, + render: function() { + return ( +
+
+ { this._codeRow = ref }} type="text" + name="code" placeholder="a94bXXXXXXXXXXXXXX" value={this.state.code} onChange={this.handleCodeChanged} + helper="A verification code is required to access this version."/> +
+ +
+
+ No code? { this.props.setStage("nocode")}} label="Click here" />. +
+ +
+ ); + } +}); + +const WelcomeStage = React.createClass({ + propTypes: { + endAuth: React.PropTypes.func, + }, + getInitialState: function() { + return { + hasReward: false, + rewardAmount: null, + } + }, + onRewardClaim: function(reward) { + this.setState({ + hasReward: true, + rewardAmount: reward.amount + }) + }, + render: function() { + return ( + !this.state.hasReward ? + +
+

Welcome to LBRY.

+

Using LBRY is like dating a centaur. Totally normal up top, and way different underneath.

+

Up top, LBRY is similar to popular media sites.

+

Below, LBRY is controlled by users -- you -- via blockchain and decentralization.

+

Thank you for making content freedom possible! Here's a nickel, kid.

+
+ this.props.setStage(null)} onConfirmed={() => { this.props.setStage(null) }} /> +
+
+
: + { this.props.setStage(null) }}> +
+

About Your Reward

+

You earned a reward of LBRY credits, or LBC.

+

This reward will show in your Wallet momentarily, probably while you are reading this message.

+

LBC is used to compensate creators, to publish, and to have say in how the network works.

+

No need to understand it all just yet! Try watching or downloading something next.

+

Finally, know that LBRY is a beta and that it earns the name.

+
+
+ ); + } +}); + + +const ErrorStage = React.createClass({ + render: function() { + return ( +
+

An error was encountered that we cannot continue from.

+

At least we're earning the name beta.

+ { this.props.errorText ?

Message: {this.props.errorText}

: '' } + { window.location.reload() } } /> +
+ ); + } +}); + +const PendingStage = React.createClass({ + render: function() { + return ( +
+

Preparing for first access

+
+ ); + } +}); + + +const CodeRequiredStage = React.createClass({ + _balanceSubscribeId: null, + getInitialState: function() { + return { + balance: 0, + address: getLocal('wallet_address') + } + }, + + componentWillMount: function() { + this._balanceSubscribeId = lbry.balanceSubscribe((balance) => { + this.setState({ + balance: balance + }); + }) + + if (!this.state.address) { + lbry.getUnusedAddress((address) => { + setLocal('wallet_address', address); + this.setState({ address: address }); + }); + } + }, + componentWillUnmount: function() { + if (this._balanceSubscribeId) { + lbry.balanceUnsubscribe(this._balanceSubscribeId) + } + }, + render: function() { + const disabled = this.state.balance < 1; + return ( +
+
+

Access to LBRY is restricted as we build and scale the network.

+

There are two ways in:

+

Own LBRY Credits

+

If you own at least 1 LBC, you can get in right now.

+

{ setLocal('auth_bypassed', true); this.props.setStage(null); }} + disabled={disabled} label="Let Me In" button={ disabled ? "alt" : "primary" } />

+

Your balance is . To increase your balance, send credits to this address:

+

+

If you don't understand how to send credits, then...

+
+
+

Wait For A Code

+

If you provide your email, you'll automatically receive a notification when the system is open.

+

{ this.props.setStage("email"); }} label="Return" />

+
+
+ ); + } +}); + + +export const AuthOverlay = React.createClass({ + _stages: { + pending: PendingStage, + error: ErrorStage, + nocode: CodeRequiredStage, + email: SubmitEmailStage, + confirm: ConfirmEmailStage, + welcome: WelcomeStage + }, + getInitialState: function() { + return { + stage: "pending", + stageProps: {} + }; + }, + setStage: function(stage, stageProps = {}) { + this.setState({ + stage: stage, + stageProps: stageProps + }) + }, + componentWillMount: function() { + lbryio.authenticate().then((user) => { + if (!user.HasVerifiedEmail) { + if (getLocal('auth_bypassed')) { + this.setStage(null) + } else { + this.setStage("email", {}) + } + } else { + lbryio.call('reward', 'list', {}).then((userRewards) => { + userRewards.filter(function(reward) { + return reward.RewardType == "new_user" && reward.TransactionID; + }).length ? + this.setStage(null) : + this.setStage("welcome") + }); + } + }).catch((err) => { + this.setStage("error", { errorText: err.message }) + document.dispatchEvent(new CustomEvent('unhandledError', { + detail: { + message: err.message, + data: err.stack + } + })); + }) + }, + render: function() { + if (!this.state.stage) { + return null; + } + const StageContent = this._stages[this.state.stage]; + return ( + this.state.stage != "welcome" ? + +

LBRY Early Access

+ +
: + + ); + } +}); \ No newline at end of file diff --git a/ui/js/component/channel-indicator.js b/ui/js/component/channel-indicator.js index 674484200..e19850c28 100644 --- a/ui/js/component/channel-indicator.js +++ b/ui/js/component/channel-indicator.js @@ -1,31 +1,29 @@ import React from 'react'; import lbry from '../lbry.js'; -import uri from '../uri.js'; +import lbryuri from '../lbryuri.js'; import {Icon} from './common.js'; -const ChannelIndicator = React.createClass({ +const UriIndicator = React.createClass({ propTypes: { uri: React.PropTypes.string.isRequired, - claimInfo: React.PropTypes.object.isRequired, + hasSignature: React.PropTypes.bool.isRequired, + signatureIsValid: React.PropTypes.bool, }, render: function() { - const {name, has_signature, signature_is_valid} = this.props.claimInfo; - if (!has_signature) { - return null; - } - const uriObj = uri.parseLbryUri(this.props.uri); - if (!uriObj.isChannel) { - return null; + const uriObj = lbryuri.parse(this.props.uri); + + if (!this.props.hasSignature || !uriObj.isChannel) { + return Anonymous; } const channelUriObj = Object.assign({}, uriObj); delete channelUriObj.path; - const channelUri = uri.buildLbryUri(channelUriObj, false); + delete channelUriObj.contentName; + const channelUri = lbryuri.build(channelUriObj, false); let icon, modifier; - if (!signature_is_valid) { - icon = 'icon-check-circle'; + if (this.props.signatureIsValid) { modifier = 'valid'; } else { icon = 'icon-times-circle'; @@ -33,11 +31,13 @@ const ChannelIndicator = React.createClass({ } return ( - by {channelUri} {' '} - + {channelUri} {' '} + { !this.props.signatureIsValid ? + : + '' } ); } }); -export default ChannelIndicator; \ No newline at end of file +export default UriIndicator; \ No newline at end of file diff --git a/ui/js/component/common.js b/ui/js/component/common.js index 9e163476c..8da20ca8e 100644 --- a/ui/js/component/common.js +++ b/ui/js/component/common.js @@ -1,6 +1,5 @@ import React from 'react'; import lbry from '../lbry.js'; -import $clamp from 'clamp-js-main'; //component/icon.js export let Icon = React.createClass({ @@ -19,29 +18,15 @@ export let Icon = React.createClass({ export let TruncatedText = React.createClass({ propTypes: { - lines: React.PropTypes.number, - height: React.PropTypes.string, - auto: React.PropTypes.bool, + lines: React.PropTypes.number }, getDefaultProps: function() { return { lines: null, - height: null, - auto: true, } }, - componentDidMount: function() { - // Manually round up the line height, because clamp.js doesn't like fractional-pixel line heights. - - // Need to work directly on the style object because setting the style prop doesn't update internal styles right away. - this.refs.span.style.lineHeight = Math.ceil(parseFloat(getComputedStyle(this.refs.span).lineHeight)) + 'px'; - - $clamp(this.refs.span, { - clamp: this.props.lines || this.props.height || 'auto', - }); - }, render: function() { - return {this.props.children}; + return {this.props.children}; } }); @@ -54,45 +39,109 @@ export let BusyMessage = React.createClass({ } }); -var creditAmountStyle = { - color: '#216C2A', - fontWeight: 'bold', - fontSize: '0.8em' -}, estimateStyle = { - fontSize: '0.8em', - color: '#aaa', -}; - export let CurrencySymbol = React.createClass({ render: function() { return LBC; } }); export let CreditAmount = React.createClass({ propTypes: { - amount: React.PropTypes.number, - precision: React.PropTypes.number + amount: React.PropTypes.number.isRequired, + precision: React.PropTypes.number, + isEstimate: React.PropTypes.bool, + label: React.PropTypes.bool, + showFree: React.PropTypes.bool, + look: React.PropTypes.oneOf(['indicator', 'plain']), + }, + getDefaultProps: function() { + return { + precision: 1, + label: true, + showFree: false, + look: 'indicator', + } }, render: function() { - var formattedAmount = lbry.formatCredits(this.props.amount, this.props.precision ? this.props.precision : 1); + const formattedAmount = lbry.formatCredits(this.props.amount, this.props.precision); + let amountText; + if (this.props.showFree && parseFloat(formattedAmount) == 0) { + amountText = 'free'; + } else if (this.props.label) { + amountText = formattedAmount + (parseFloat(formattedAmount) == 1 ? ' credit' : ' credits'); + } else { + amountText = formattedAmount; + } + return ( - - {formattedAmount} {parseFloat(formattedAmount) == 1.0 ? 'credit' : 'credits'} - { this.props.isEstimate ? (est) : null } + + + {amountText} + + { this.props.isEstimate ? * : null } ); } }); +export let FilePrice = React.createClass({ + _isMounted: false, + + propTypes: { + uri: React.PropTypes.string.isRequired, + look: React.PropTypes.oneOf(['indicator', 'plain']), + }, + + getDefaultProps: function() { + return { + look: 'indicator', + } + }, + + componentWillMount: function() { + this.setState({ + cost: null, + isEstimate: null, + }); + }, + + componentDidMount: function() { + this._isMounted = true; + lbry.getCostInfo(this.props.uri).then(({cost, includesData}) => { + if (this._isMounted) { + this.setState({ + cost: cost, + isEstimate: !includesData, + }); + } + }, (err) => { + // If we get an error looking up cost information, do nothing + }); + }, + + componentWillUnmount: function() { + this._isMounted = false; + }, + + render: function() { + if (this.state.cost === null) { + return ???; + } + + return + } +}); + var addressStyle = { fontFamily: '"Consolas", "Lucida Console", "Adobe Source Code Pro", monospace', }; export let Address = React.createClass({ + _inputElem: null, propTypes: { address: React.PropTypes.string, }, render: function() { return ( - {this.props.address} + { this._inputElem = input; }} + onFocus={() => { this._inputElem.select(); }} style={addressStyle} readOnly="readonly" value={this.props.address}> ); } }); @@ -131,6 +180,9 @@ export let Thumbnail = React.createClass({ this._isMounted = false; }, render: function() { - return + const className = this.props.className ? this.props.className : '', + otherProps = Object.assign({}, this.props) + delete otherProps.className; + return }, }); diff --git a/ui/js/component/drawer.js b/ui/js/component/drawer.js deleted file mode 100644 index eaf11506b..000000000 --- a/ui/js/component/drawer.js +++ /dev/null @@ -1,67 +0,0 @@ -import lbry from '../lbry.js'; -import React from 'react'; -import {Link} from './link.js'; - -var DrawerItem = React.createClass({ - getDefaultProps: function() { - return { - subPages: [], - }; - }, - render: function() { - var isSelected = (this.props.viewingPage == this.props.href.substr(1) || - this.props.subPages.indexOf(this.props.viewingPage) != -1); - return - } -}); - -var drawerImageStyle = { //@TODO: remove this, img should be properly scaled once size is settled - height: '36px' -}; - -var Drawer = React.createClass({ - _balanceSubscribeId: null, - - handleLogoClicked: function(event) { - if ((event.ctrlKey || event.metaKey) && event.shiftKey) { - window.location.href = '?developer' - event.preventDefault(); - } - }, - getInitialState: function() { - return { - balance: 0, - }; - }, - componentDidMount: function() { - this._balanceSubscribeId = lbry.balanceSubscribe(function(balance) { - this.setState({ - balance: balance - }); - }.bind(this)); - }, - componentWillUnmount: function() { - if (this._balanceSubscribeId) { - lbry.balanceUnsubscribe(this._balanceSubscribeId) - } - }, - render: function() { - return ( - - ); - } -}); - - -export default Drawer; diff --git a/ui/js/component/file-actions.js b/ui/js/component/file-actions.js index 863c9b9f2..21d25eb47 100644 --- a/ui/js/component/file-actions.js +++ b/ui/js/component/file-actions.js @@ -1,76 +1,24 @@ import React from 'react'; import lbry from '../lbry.js'; +import lbryuri from '../lbryuri.js'; import {Link} from '../component/link.js'; -import {Icon} from '../component/common.js'; -import Modal from './modal.js'; -import FormField from './form.js'; +import {Icon, FilePrice} from '../component/common.js'; +import {Modal} from './modal.js'; +import {FormField} from './form.js'; import {ToolTip} from '../component/tooltip.js'; import {DropDownMenu, DropDownMenuItem} from './menu.js'; const {shell} = require('electron'); -let WatchLink = React.createClass({ - propTypes: { - streamName: React.PropTypes.string, - downloadStarted: React.PropTypes.bool, - }, - startVideo: function() { - window.location = '?watch=' + this.props.streamName; - }, - handleClick: function() { - this.setState({ - loading: true, - }); - - if (this.props.downloadStarted) { - this.startVideo(); - } else { - lbry.getCostInfoForName(this.props.streamName, ({cost}) => { - lbry.getBalance((balance) => { - if (cost > balance) { - this.setState({ - modal: 'notEnoughCredits', - loading: false, - }); - } else { - this.startVideo(); - } - }); - }); - } - }, - getInitialState: function() { - return { - modal: null, - loading: false, - }; - }, - closeModal: function() { - this.setState({ - modal: null, - }); - }, - render: function() { - return ( -
- - - You don't have enough LBRY credits to pay for this stream. - -
- ); - } -}); - let FileActionsRow = React.createClass({ _isMounted: false, _fileInfoSubscribeId: null, propTypes: { - streamName: React.PropTypes.string, + uri: React.PropTypes.string, outpoint: React.PropTypes.string.isRequired, metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]), - contentType: React.PropTypes.string, + contentType: React.PropTypes.string.isRequired, }, getInitialState: function() { return { @@ -79,7 +27,7 @@ let FileActionsRow = React.createClass({ menuOpen: false, deleteChecked: false, attemptingDownload: false, - attemptingRemove: false + attemptingRemove: false, } }, onFileInfoUpdate: function(fileInfo) { @@ -95,15 +43,15 @@ let FileActionsRow = React.createClass({ attemptingDownload: true, attemptingRemove: false }); - lbry.getCostInfoForName(this.props.streamName, ({cost}) => { + lbry.getCostInfo(this.props.uri).then(({cost}) => { lbry.getBalance((balance) => { if (cost > balance) { this.setState({ modal: 'notEnoughCredits', attemptingDownload: false, }); - } else { - lbry.getStream(this.props.streamName, (streamInfo) => { + } else if (this.state.affirmedPurchase) { + lbry.get({uri: this.props.uri}).then((streamInfo) => { if (streamInfo === null || typeof streamInfo !== 'object') { this.setState({ modal: 'timedOut', @@ -111,6 +59,11 @@ let FileActionsRow = React.createClass({ }); } }); + } else { + this.setState({ + attemptingDownload: false, + modal: 'affirmPurchase' + }) } }); }); @@ -153,6 +106,13 @@ let FileActionsRow = React.createClass({ attemptingDownload: false }); }, + onAffirmPurchase: function() { + this.setState({ + affirmedPurchase: true, + modal: null + }); + this.tryDownload(); + }, openMenu: function() { this.setState({ menuOpen: !this.state.menuOpen, @@ -196,11 +156,10 @@ let FileActionsRow = React.createClass({ linkBlock = ; } + const uri = lbryuri.normalize(this.props.uri); + const title = this.props.metadata ? this.props.metadata.title : uri; return (
- {this.props.contentType && this.props.contentType.startsWith('video/') - ? - : null} {this.state.fileInfo !== null || this.state.fileInfo.isMine ? linkBlock : null} @@ -209,18 +168,22 @@ let FileActionsRow = React.createClass({ : '' } + + Are you sure you'd like to buy {title} for credits? + You don't have enough LBRY credits to pay for this stream. - LBRY was unable to download the stream lbry://{this.props.streamName}. + LBRY was unable to download the stream {uri}. -

Are you sure you'd like to remove {this.props.metadata.title} from LBRY?

+

Are you sure you'd like to remove {title} from LBRY?

@@ -234,7 +197,7 @@ export let FileActions = React.createClass({ _fileInfoSubscribeId: null, propTypes: { - streamName: React.PropTypes.string, + uri: React.PropTypes.string, outpoint: React.PropTypes.string.isRequired, metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]), contentType: React.PropTypes.string, @@ -261,7 +224,8 @@ export let FileActions = React.createClass({ componentDidMount: function() { this._isMounted = true; this._fileInfoSubscribeId = lbry.fileInfoSubscribe(this.props.outpoint, this.onFileInfoUpdate); - lbry.getStreamAvailability(this.props.uri, (availability) => { + + lbry.get_availability({uri: this.props.uri}, (availability) => { if (this._isMounted) { this.setState({ available: availability > 0, @@ -291,10 +255,10 @@ export let FileActions = React.createClass({ return (
{ fileInfo || this.state.available || this.state.forceShowActions - ? :
-
This file is not currently available.
+
Content unavailable.
diff --git a/ui/js/component/file-tile.js b/ui/js/component/file-tile.js index bb8438939..73adb63b2 100644 --- a/ui/js/component/file-tile.js +++ b/ui/js/component/file-tile.js @@ -1,67 +1,23 @@ import React from 'react'; import lbry from '../lbry.js'; +import lbryuri from '../lbryuri.js'; import {Link} from '../component/link.js'; import {FileActions} from '../component/file-actions.js'; -import {Thumbnail, TruncatedText, CreditAmount} from '../component/common.js'; -import ChannelIndicator from '../component/channel-indicator.js'; - -let FilePrice = React.createClass({ - _isMounted: false, - - propTypes: { - uri: React.PropTypes.string - }, - - getInitialState: function() { - return { - cost: null, - costIncludesData: null, - } - }, - - componentDidMount: function() { - this._isMounted = true; - - lbry.getCostInfoForName(this.props.uri, ({cost, includesData}) => { - if (this._isMounted) { - this.setState({ - cost: cost, - costIncludesData: includesData, - }); - } - }, () => { - // If we get an error looking up cost information, do nothing - }); - }, - - componentWillUnmount: function() { - this._isMounted = false; - }, - - render: function() { - if (this.state.cost === null) - { - return null; - } - - return ( - - - - ); - } -}); +import {BusyMessage, TruncatedText, FilePrice} from '../component/common.js'; +import UriIndicator from '../component/channel-indicator.js'; /*should be merged into FileTile once FileTile is refactored to take a single id*/ export let FileTileStream = React.createClass({ _fileInfoSubscribeId: null, _isMounted: null, - _metadata: null, propTypes: { uri: React.PropTypes.string, - claimInfo: React.PropTypes.object, + metadata: React.PropTypes.object, + contentType: React.PropTypes.string.isRequired, outpoint: React.PropTypes.string, + hasSignature: React.PropTypes.bool, + signatureIsValid: React.PropTypes.bool, hideOnRemove: React.PropTypes.bool, hidePrice: React.PropTypes.bool, obscureNsfw: React.PropTypes.bool @@ -70,20 +26,15 @@ export let FileTileStream = React.createClass({ return { showNsfwHelp: false, isHidden: false, - available: null, } }, getDefaultProps: function() { return { obscureNsfw: !lbry.getClientSetting('showNsfw'), - hidePrice: false + hidePrice: false, + hasSignature: false, } }, - componentWillMount: function() { - const {value: {stream: {metadata, source: {contentType}}}} = this.props.claimInfo; - this._metadata = metadata; - this._contentType = contentType; - }, componentDidMount: function() { this._isMounted = true; if (this.props.hideOnRemove) { @@ -103,7 +54,7 @@ export let FileTileStream = React.createClass({ } }, handleMouseOver: function() { - if (this.props.obscureNsfw && this.props.metadata && this._metadata.nsfw) { + if (this.props.obscureNsfw && this.props.metadata && this.props.metadata.nsfw) { this.setState({ showNsfwHelp: true, }); @@ -121,39 +72,37 @@ export let FileTileStream = React.createClass({ return null; } - const metadata = this._metadata; - const isConfirmed = typeof metadata == 'object'; - const title = isConfirmed ? metadata.title : ('lbry://' + this.props.uri); + const uri = lbryuri.normalize(this.props.uri); + const metadata = this.props.metadata; + const isConfirmed = !!metadata; + const title = isConfirmed ? metadata.title : uri; const obscureNsfw = this.props.obscureNsfw && isConfirmed && metadata.nsfw; + const primaryUrl = "?show=" + uri; return ( -
-
-
- -
-
- { !this.props.hidePrice - ? - : null} - -

- - - {title} +
+ +

- - -

- - {isConfirmed - ? metadata.description - : This file is pending confirmation.} - -

+
+
-
+ {this.state.showNsfwHelp ?

@@ -167,12 +116,113 @@ export let FileTileStream = React.createClass({ } }); +export let FileCardStream = React.createClass({ + _fileInfoSubscribeId: null, + _isMounted: null, + _metadata: null, + + + propTypes: { + uri: React.PropTypes.string, + claimInfo: React.PropTypes.object, + outpoint: React.PropTypes.string, + hideOnRemove: React.PropTypes.bool, + hidePrice: React.PropTypes.bool, + obscureNsfw: React.PropTypes.bool + }, + getInitialState: function() { + return { + showNsfwHelp: false, + isHidden: false, + } + }, + getDefaultProps: function() { + return { + obscureNsfw: !lbry.getClientSetting('showNsfw'), + hidePrice: false, + hasSignature: false, + } + }, + componentDidMount: function() { + this._isMounted = true; + if (this.props.hideOnRemove) { + this._fileInfoSubscribeId = lbry.fileInfoSubscribe(this.props.outpoint, this.onFileInfoUpdate); + } + }, + componentWillUnmount: function() { + if (this._fileInfoSubscribeId) { + lbry.fileInfoUnsubscribe(this.props.outpoint, this._fileInfoSubscribeId); + } + }, + onFileInfoUpdate: function(fileInfo) { + if (!fileInfo && this._isMounted && this.props.hideOnRemove) { + this.setState({ + isHidden: true + }); + } + }, + handleMouseOver: function() { + this.setState({ + hovered: true, + }); + }, + handleMouseOut: function() { + this.setState({ + hovered: false, + }); + }, + render: function() { + if (this.state.isHidden) { + return null; + } + + const uri = lbryuri.normalize(this.props.uri); + const metadata = this.props.metadata; + const isConfirmed = !!metadata; + const title = isConfirmed ? metadata.title : uri; + const obscureNsfw = this.props.obscureNsfw && isConfirmed && metadata.nsfw; + const primaryUrl = '?show=' + uri; + return ( +

+
+ +
+
{title}
+
+ { !this.props.hidePrice ? : null} + +
+
+
+
+ + {isConfirmed + ? metadata.description + : This file is pending confirmation.} + +
+
+ {this.state.showNsfwHelp && this.state.hovered + ?
+

+ This content is Not Safe For Work. + To view adult content, please change your . +

+
+ : null} +
+
+ ); + } +}); + export let FileTile = React.createClass({ _isMounted: false, + _isResolvePending: false, propTypes: { uri: React.PropTypes.string.isRequired, - available: React.PropTypes.bool, }, getInitialState: function() { @@ -181,30 +231,54 @@ export let FileTile = React.createClass({ claimInfo: null } }, - - componentDidMount: function() { - this._isMounted = true; - - lbry.resolve({uri: this.props.uri}).then(({claim: claimInfo}) => { - const {value: {stream: {metadata}}, txid, nout} = claimInfo; - if (this._isMounted && claimInfo.value.stream.metadata) { + resolve: function(uri) { + this._isResolvePending = true; + lbry.resolve({uri: uri}).then((resolutionInfo) => { + this._isResolvePending = false; + if (this._isMounted && resolutionInfo && resolutionInfo.claim && resolutionInfo.claim.value && + resolutionInfo.claim.value.stream && resolutionInfo.claim.value.stream.metadata) { // In case of a failed lookup, metadata will be null, in which case the component will never display this.setState({ - outpoint: txid + ':' + nout, - claimInfo: claimInfo, + claimInfo: resolutionInfo.claim, }); } }); }, + componentWillReceiveProps: function(nextProps) { + if (nextProps.uri != this.props.uri) { + this.setState(this.getInitialState()); + this.resolve(nextProps.uri); + } + }, + componentDidMount: function() { + this._isMounted = true; + this.resolve(this.props.uri); + }, componentWillUnmount: function() { this._isMounted = false; }, render: function() { - if (!this.state.claimInfo || !this.state.outpoint) { + if (!this.state.claimInfo) { + if (this.props.displayStyle == 'card') { + return + } + if (this.props.showEmpty) + { + return this._isResolvePending ? + : +
{lbryuri.normalize(this.props.uri)} is unclaimed.
; + } return null; } - return ; + const {txid, nout, has_signature, signature_is_valid, + value: {stream: {metadata, source: {contentType}}}} = this.state.claimInfo; + + return this.props.displayStyle == 'card' ? + : + ; } }); diff --git a/ui/js/component/form.js b/ui/js/component/form.js index 33e4aee66..f75310c92 100644 --- a/ui/js/component/form.js +++ b/ui/js/component/form.js @@ -1,28 +1,32 @@ import React from 'react'; import {Icon} from './common.js'; -var requiredFieldWarningStyle = { - color: '#cc0000', - transition: 'opacity 400ms ease-in', -}; +var formFieldCounter = 0, + formFieldNestedLabelTypes = ['radio', 'checkbox']; -var FormField = React.createClass({ +function formFieldId() { + return "form-field-" + (++formFieldCounter); +} + +export let FormField = React.createClass({ _fieldRequiredText: 'This field is required', _type: null, _element: null, propTypes: { type: React.PropTypes.string.isRequired, - hidden: React.PropTypes.bool, + prefix: React.PropTypes.string, + postfix: React.PropTypes.string, + hasError: React.PropTypes.bool }, getInitialState: function() { return { - adviceState: 'hidden', - adviceText: null, + isError: null, + errorMessage: null, } }, componentWillMount: function() { - if (['text', 'radio', 'checkbox', 'file'].includes(this.props.type)) { + if (['text', 'number', 'radio', 'checkbox', 'file'].includes(this.props.type)) { this._element = 'input'; this._type = this.props.type; } else if (this.props.type == 'text-number') { @@ -33,25 +37,11 @@ var FormField = React.createClass({ this._element = this.props.type; } }, - showAdvice: function(text) { + showError: function(text) { this.setState({ - adviceState: 'shown', - adviceText: text, + isError: true, + errorMessage: text, }); - - setTimeout(() => { - this.setState({ - adviceState: 'fading', - }); - setTimeout(() => { - this.setState({ - adviceState: 'hidden', - }); - }, 450); - }, 5000); - }, - warnRequired: function() { - this.showAdvice(this._fieldRequiredText); }, focus: function() { this.refs.field.focus(); @@ -60,7 +50,8 @@ var FormField = React.createClass({ if (this.props.type == 'checkbox') { return this.refs.field.checked; } else if (this.props.type == 'file') { - return this.refs.field.files[0].path; + return this.refs.field.files.length && this.refs.field.files[0].path ? + this.refs.field.files[0].path : null; } else { return this.refs.field.value; } @@ -70,45 +61,94 @@ var FormField = React.createClass({ }, render: function() { // Pass all unhandled props to the field element - const otherProps = Object.assign({}, this.props); + const otherProps = Object.assign({}, this.props), + isError = this.state.isError !== null ? this.state.isError : this.props.hasError, + elementId = this.props.id ? this.props.id : formFieldId(), + renderElementInsideLabel = this.props.label && formFieldNestedLabelTypes.includes(this.props.type); + delete otherProps.type; - delete otherProps.hidden; + delete otherProps.label; + delete otherProps.hasError; + delete otherProps.className; + delete otherProps.postfix; + delete otherProps.prefix; - return ( - !this.props.hidden - ?
- - {this.props.children} - - {this.state.adviceText} -
- : null - ); + const element = + {this.props.children} + ; + + return
+ { this.props.prefix ? {this.props.prefix} : '' } + { renderElementInsideLabel ? + : + element } + { this.props.postfix ? {this.props.postfix} : '' } + { isError && this.state.errorMessage ?
{this.state.errorMessage}
: '' } +
} -}); +}) -var FormFieldAdvice = React.createClass({ +export let FormRow = React.createClass({ + _fieldRequiredText: 'This field is required', propTypes: { - state: React.PropTypes.string.isRequired, + label: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element]) + // helper: React.PropTypes.html, + }, + getInitialState: function() { + return { + isError: false, + errorMessage: null, + } + }, + showError: function(text) { + this.setState({ + isError: true, + errorMessage: text, + }); + }, + showRequiredError: function() { + this.showError(this._fieldRequiredText); + }, + clearError: function(text) { + this.setState({ + isError: false, + errorMessage: '' + }); + }, + getValue: function() { + return this.refs.field.getValue(); + }, + getSelectedElement: function() { + return this.refs.field.getSelectedElement(); + }, + focus: function() { + this.refs.field.focus(); }, render: function() { - return ( - this.props.state != 'hidden' - ?
-
- -
- - {this.props.children} - -
-
-
- : null - ); - } -}); + const fieldProps = Object.assign({}, this.props), + elementId = formFieldId(), + renderLabelInFormField = formFieldNestedLabelTypes.includes(this.props.type); -export default FormField; + if (!renderLabelInFormField) { + delete fieldProps.label; + } + delete fieldProps.helper; + + return
+ { this.props.label && !renderLabelInFormField ? +
+ +
: '' } + + { !this.state.isError && this.props.helper ?
{this.props.helper}
: '' } + { this.state.isError ?
{this.state.errorMessage}
: '' } +
+ } +}) diff --git a/ui/js/component/header.js b/ui/js/component/header.js index fb64e9ece..848f076a2 100644 --- a/ui/js/component/header.js +++ b/ui/js/component/header.js @@ -1,74 +1,198 @@ import React from 'react'; +import lbryuri from '../lbryuri.js'; import {Link} from './link.js'; +import {Icon, CreditAmount} from './common.js'; var Header = React.createClass({ + _balanceSubscribeId: null, + _isMounted: false, + + propTypes: { + onSearch: React.PropTypes.func.isRequired, + onSubmit: React.PropTypes.func.isRequired + }, + getInitialState: function() { return { - title: "LBRY", - isScrolled: false + balance: 0 }; }, - componentWillMount: function() { - new MutationObserver(function(mutations) { - this.setState({ title: mutations[0].target.textContent }); - }.bind(this)).observe( - document.querySelector('title'), - { subtree: true, characterData: true, childList: true } - ); - }, componentDidMount: function() { - document.addEventListener('scroll', this.handleScroll); - }, - componentWillUnmount: function() { - document.removeEventListener('scroll', this.handleScroll); - if (this.userTypingTimer) - { - clearTimeout(this.userTypingTimer); - } - }, - handleScroll: function() { - this.setState({ - isScrolled: document.body.scrollTop > 0 + this._isMounted = true; + this._balanceSubscribeId = lbry.balanceSubscribe((balance) => { + if (this._isMounted) { + this.setState({balance: balance}); + } }); }, - onQueryChange: function(event) { - - if (this.userTypingTimer) - { - clearTimeout(this.userTypingTimer); + componentWillUnmount: function() { + this._isMounted = false; + if (this._balanceSubscribeId) { + lbry.balanceUnsubscribe(this._balanceSubscribeId) } - - //@TODO: Switch to React.js timing - var searchTerm = event.target.value; - this.userTypingTimer = setTimeout(() => { - this.props.onSearch(searchTerm); - }, 800); // 800ms delay, tweak for faster/slower - }, render: function() { - return ( -
); diff --git a/ui/js/component/modal-page.js b/ui/js/component/modal-page.js new file mode 100644 index 000000000..12826a81e --- /dev/null +++ b/ui/js/component/modal-page.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactModal from 'react-modal'; + +export const ModalPage = React.createClass({ + render: function() { + return ( + +
+ {this.props.children} +
+
+ ); + } +}); + +export default ModalPage; \ No newline at end of file diff --git a/ui/js/component/modal.js b/ui/js/component/modal.js index bd534ecea..27250a8a3 100644 --- a/ui/js/component/modal.js +++ b/ui/js/component/modal.js @@ -6,6 +6,7 @@ import {Link} from './link.js'; export const Modal = React.createClass({ propTypes: { type: React.PropTypes.oneOf(['alert', 'confirm', 'custom']), + overlay: React.PropTypes.bool, onConfirmed: React.PropTypes.func, onAborted: React.PropTypes.func, confirmButtonLabel: React.PropTypes.string, @@ -16,6 +17,7 @@ export const Modal = React.createClass({ getDefaultProps: function() { return { type: 'alert', + overlay: true, confirmButtonLabel: 'OK', abortButtonLabel: 'Cancel', confirmButtonDisabled: false, @@ -26,7 +28,7 @@ export const Modal = React.createClass({ return ( + overlayClassName={![null, undefined, ""].includes(this.props.overlayClassName) ? this.props.overlayClassName : 'modal-overlay'}>
{this.props.children}
diff --git a/ui/js/component/notice.js b/ui/js/component/notice.js new file mode 100644 index 000000000..068b545b5 --- /dev/null +++ b/ui/js/component/notice.js @@ -0,0 +1,21 @@ +import React from 'react'; + +export const Notice = React.createClass({ + propTypes: { + isError: React.PropTypes.bool, + }, + getDefaultProps: function() { + return { + isError: false, + }; + }, + render: function() { + return ( +
+ {this.props.children} +
+ ); + }, +}); + +export default Notice; \ No newline at end of file diff --git a/ui/js/component/snack-bar.js b/ui/js/component/snack-bar.js new file mode 100644 index 000000000..6d6f17915 --- /dev/null +++ b/ui/js/component/snack-bar.js @@ -0,0 +1,57 @@ +import React from 'react'; +import lbry from '../lbry.js'; + +export const SnackBar = React.createClass({ + + _displayTime: 5, // in seconds + + _hideTimeout: null, + + getInitialState: function() { + return { + snacks: [] + } + }, + handleSnackReceived: function(event) { + // if (this._hideTimeout) { + // clearTimeout(this._hideTimeout); + // } + + let snacks = this.state.snacks; + snacks.push(event.detail); + this.setState({ snacks: snacks}); + }, + componentWillMount: function() { + document.addEventListener('globalNotice', this.handleSnackReceived); + }, + componentWillUnmount: function() { + document.removeEventListener('globalNotice', this.handleSnackReceived); + }, + render: function() { + if (!this.state.snacks.length) { + this._hideTimeout = null; //should be unmounting anyway, but be safe? + return null; + } + + let snack = this.state.snacks[0]; + + if (this._hideTimeout === null) { + this._hideTimeout = setTimeout(() => { + this._hideTimeout = null; + let snacks = this.state.snacks; + snacks.shift(); + this.setState({ snacks: snacks }); + }, this._displayTime * 1000); + } + + return ( +
+ {snack.message} + {snack.linkText && snack.linkTarget ? + {snack.linkText} : ''} +
+ ); + }, +}); + +export default SnackBar; \ No newline at end of file diff --git a/ui/js/component/splash.js b/ui/js/component/splash.js index bba92e288..a156718b4 100644 --- a/ui/js/component/splash.js +++ b/ui/js/component/splash.js @@ -13,11 +13,12 @@ var SplashScreen = React.createClass({ isLagging: false, } }, - updateStatus: function(was_lagging=false) { - lbry.getDaemonStatus(this._updateStatusCallback); + updateStatus: function() { + lbry.status().then(this._updateStatusCallback); }, _updateStatusCallback: function(status) { - if (status.code == 'started') { + const startupStatus = status.startup_status + if (startupStatus.code == 'started') { // Wait until we are able to resolve a name before declaring // that we are done. // TODO: This is a hack, and the logic should live in the daemon @@ -27,27 +28,35 @@ var SplashScreen = React.createClass({ isLagging: false }); - lbry.resolveName('one', () => { - window.sessionStorage.setItem('loaded', 'y') + lbry.resolve({uri: 'lbry://one'}).then(() => { + window.sessionStorage.setItem('loaded', 'y') this.props.onLoadDone(); }); return; } this.setState({ - details: status.message + (status.is_lagging ? '' : '...'), - isLagging: status.is_lagging, + details: startupStatus.message + (startupStatus.is_lagging ? '' : '...'), + isLagging: startupStatus.is_lagging, }); setTimeout(() => { - this.updateStatus(status.is_lagging); + this.updateStatus(); }, 500); }, componentDidMount: function() { - lbry.connect((connected) => { - this.updateStatus(); - }); + lbry.connect().then((isConnected) => { + if (isConnected) { + this.updateStatus(); + } else { + this.setState({ + isLagging: true, + message: "Failed to connect to LBRY", + details: "LBRY was unable to start and connect properly." + }) + } + }) }, render: function() { - return ; + return } }); diff --git a/ui/js/lbry.js b/ui/js/lbry.js index 2105abfb4..d3c02e231 100644 --- a/ui/js/lbry.js +++ b/ui/js/lbry.js @@ -1,32 +1,46 @@ +import lbryio from './lbryio.js'; import lighthouse from './lighthouse.js'; import jsonrpc from './jsonrpc.js'; -import {getLocal, setLocal} from './utils.js'; +import lbryuri from './lbryuri.js'; +import {getLocal, getSession, setSession, setLocal} from './utils.js'; -const {remote} = require('electron'); +const {remote, ipcRenderer} = 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) { +function savePendingPublish({name, channel_name}) { + let uri; + if (channel_name) { + uri = lbryuri.build({name: channel_name, path: name}, false); + } else { + uri = lbryuri.build({name: name}, false); + } const pendingPublishes = getLocal('pendingPublishes') || []; const newPendingPublish = { - claim_id: 'pending_claim_' + name, - txid: 'pending_' + name, + name, channel_name, + claim_id: 'pending_claim_' + uri, + txid: 'pending_' + uri, nout: 0, - outpoint: 'pending_' + name + ':0', - name: name, + outpoint: 'pending_' + uri + ':0', time: Date.now(), }; setLocal('pendingPublishes', [...pendingPublishes, newPendingPublish]); return newPendingPublish; } -function removePendingPublish({name, outpoint}) { - setLocal('pendingPublishes', getPendingPublishes().filter( - (pub) => pub.name != name && pub.outpoint != outpoint - )); +/** + * If there is a pending publish with the given name or outpoint, remove it. + * A channel name may also be provided along with name. + */ +function removePendingPublishIfNeeded({name, channel_name, outpoint}) { + function pubMatches(pub) { + return pub.outpoint === outpoint || (pub.name === name && (!channel_name || pub.channel_name === channel_name)); + } + + setLocal('pendingPublishes', getPendingPublishes().filter(pub => !pubMatches(pub))); } /** @@ -35,61 +49,30 @@ function removePendingPublish({name, outpoint}) { */ function getPendingPublishes() { const pendingPublishes = getLocal('pendingPublishes') || []; - - const newPendingPublishes = []; - for (let pendingPublish of pendingPublishes) { - if (Date.now() - pendingPublish.time <= lbry.pendingPublishTimeout) { - newPendingPublishes.push(pendingPublish); - } - } + const newPendingPublishes = pendingPublishes.filter(pub => Date.now() - pub.time <= lbry.pendingPublishTimeout); setLocal('pendingPublishes', newPendingPublishes); - return 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. + * Gets a pending publish attempt by its name or (fake) outpoint. A channel name can also be + * provided along withe the name. If no pending publish is found, returns null. */ -function getPendingPublish({name, outpoint}) { +function getPendingPublish({name, channel_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; - } + return pendingPublishes.find( + pub => pub.outpoint === outpoint || (pub.name === name && (!channel_name || pub.channel_name === channel_name)) + ) || null; } -function pendingPublishToDummyClaim({name, outpoint, claim_id, txid, nout}) { - return { - name: name, - outpoint: outpoint, - claim_id: claim_id, - txid: txid, - nout: nout, - }; +function pendingPublishToDummyClaim({channel_name, name, outpoint, claim_id, txid, nout}) { + return {name, outpoint, claim_id, txid, nout, channel_name}; } function pendingPublishToDummyFileInfo({name, outpoint, claim_id}) { - return { - name: name, - outpoint: outpoint, - claim_id: claim_id, - metadata: "Attempting publication", - }; + return {name, outpoint, claim_id, metadata: null}; } - +window.pptdfi = pendingPublishToDummyFileInfo; let lbry = { isConnected: false, @@ -112,33 +95,55 @@ let lbry = { }; lbry.call = function (method, params, callback, errorCallback, connectFailedCallback) { - jsonrpc.call(lbry.daemonConnectionString, method, [params], callback, errorCallback, connectFailedCallback); + jsonrpc.call(lbry.daemonConnectionString, method, params, callback, errorCallback, connectFailedCallback); +} + +//core +lbry._connectPromise = null; +lbry.connect = function() { + if (lbry._connectPromise === null) { + + lbry._connectPromise = new Promise((resolve, reject) => { + + // Check every half second to see if the daemon is accepting connections + function checkDaemonStarted(tryNum = 0) { + lbry.isDaemonAcceptingConnections(function (runningStatus) { + if (runningStatus) { + resolve(true); + } + else { + if (tryNum <= 600) { // Move # of tries into constant or config option + setTimeout(function () { + checkDaemonStarted(tryNum + 1); + }, tryNum < 100 ? 200 : 1000); + } + else { + reject(new Error("Unable to connect to LBRY")); + } + } + }); + } + + checkDaemonStarted(); + }); + } + + return lbry._connectPromise; } -//core -lbry.connect = function(callback) -{ - // Check every half second to see if the daemon is accepting connections - // Once this returns True, can call getDaemonStatus to see where - // we are in the startup process - function checkDaemonStarted(tryNum=0) { - lbry.isDaemonAcceptingConnections(function (runningStatus) { - if (runningStatus) { - lbry.isConnected = true; - callback(true); - } else { - if (tryNum <= 600) { // Move # of tries into constant or config option - setTimeout(function () { - checkDaemonStarted(tryNum + 1); - }, 500); - } else { - callback(false); - } - } - }); +//kill this but still better than document.title =, which this replaced +lbry.setTitle = function(title) { + document.title = title + " - LBRY"; +} + +//kill this with proper routing +lbry.back = function() { + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.href = "?discover"; } - checkDaemonStarted(); } lbry.isDaemonAcceptingConnections = function (callback) { @@ -146,10 +151,6 @@ lbry.isDaemonAcceptingConnections = function (callback) { lbry.call('status', {}, () => callback(true), null, () => callback(false)) }; -lbry.getDaemonStatus = function (callback) { - lbry.call('daemon_status', {}, callback); -}; - lbry.checkFirstRun = function(callback) { lbry.call('is_first_run', {}, callback); } @@ -189,23 +190,6 @@ lbry.sendToAddress = function(amount, address, callback, errorCallback) { lbry.call("send_amount_to_address", { "amount" : amount, "address": address }, callback, errorCallback); } -lbry.resolveName = function(name, callback) { - if (!name) { - throw new Error(`Name required.`); - } - lbry.call('resolve_name', { 'name': name }, callback, () => { - // For now, assume any error means the name was not resolved - callback(null); - }); -} - -lbry.getStream = function(name, callback) { - if (!name) { - throw new Error(`Name required.`); - } - lbry.call('get', { 'name': name }, callback); -}; - lbry.getClaimInfo = function(name, callback) { if (!name) { throw new Error(`Name required.`); @@ -219,23 +203,6 @@ lbry.getMyClaim = function(name, callback) { }); } -lbry.getKeyFee = function(name, callback, errorCallback) { - if (!name) { - throw new Error(`Name required.`); - } - lbry.call('stream_cost_estimate', { name: name }, callback, errorCallback); -} - -lbry.getTotalCost = function(name, size, callback, errorCallback) { - if (!name) { - throw new Error(`Name required.`); - } - lbry.call('stream_cost_estimate', { - name: name, - size: size, - }, callback, errorCallback); -} - lbry.getPeersForBlobHash = function(blobHash, callback) { let timedOut = false; const timeout = setTimeout(() => { @@ -251,88 +218,87 @@ lbry.getPeersForBlobHash = function(blobHash, callback) { }); } -lbry.getStreamAvailability = function(name, callback, errorCallback) { - if (!name) { - throw new Error(`Name required.`); - } - lbry.call('get_availability', {name: name}, callback, errorCallback); -} +/** + * Takes a LBRY URI; will first try and calculate a total cost using + * Lighthouse. If Lighthouse can't be reached, it just retrives the + * key fee. + * + * Returns an object with members: + * - cost: Number; the calculated cost of the name + * - includes_data: Boolean; indicates whether or not the data fee info + * from Lighthouse is included. + */ +lbry.costPromiseCache = {} +lbry.getCostInfo = function(uri) { + if (lbry.costPromiseCache[uri] === undefined) { + lbry.costPromiseCache[uri] = new Promise((resolve, reject) => { + const COST_INFO_CACHE_KEY = 'cost_info_cache'; + let costInfoCache = getSession(COST_INFO_CACHE_KEY, {}) -lbry.getCostInfoForName = function(name, callback, errorCallback) { - /** - * Takes a LBRY name; will first try and calculate a total cost using - * Lighthouse. If Lighthouse can't be reached, it just retrives the - * key fee. - * - * Returns an object with members: - * - cost: Number; the calculated cost of the name - * - includes_data: Boolean; indicates whether or not the data fee info - * from Lighthouse is included. - */ - if (!name) { - throw new Error(`Name required.`); - } - - function getCostWithData(name, size, callback, errorCallback) { - lbry.getTotalCost(name, size, (cost) => { - callback({ - cost: cost, - includesData: true, - }); - }, errorCallback); - } - - function getCostNoData(name, callback, errorCallback) { - lbry.getKeyFee(name, (cost) => { - callback({ - cost: cost, - includesData: false, - }); - }, errorCallback); - } - - lighthouse.get_size_for_name(name).then((size) => { - getCostWithData(name, size, callback, errorCallback); - }, () => { - getCostNoData(name, callback, errorCallback); - }); -} - -lbry.getFeaturedDiscoverNames = function(callback) { - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', 'https://api.lbry.io/discover/list', true); - xhr.onload = () => { - if (xhr.status === 200) { - var responseData = JSON.parse(xhr.responseText); - if (responseData.data) //new signature, once api.lbry.io is updated - { - resolve(responseData.data); - } - else - { - resolve(responseData); - } - } else { - reject(Error('Failed to fetch featured names.')); + function cacheAndResolve(cost, includesData) { + costInfoCache[uri] = {cost, includesData}; + setSession(COST_INFO_CACHE_KEY, costInfoCache); + resolve({cost, includesData}); } - }; - xhr.send(); - }); + + if (!uri) { + return reject(new Error(`URI required.`)); + } + + if (costInfoCache[uri] && costInfoCache[uri].cost) { + return resolve(costInfoCache[uri]) + } + + function getCost(uri, size) { + lbry.stream_cost_estimate({uri, ... size !== null ? {size} : {}}).then((cost) => { + cacheAndResolve(cost, size !== null); + }, reject); + } + + function getCostGenerous(uri) { + // If generous is on, the calculation is simple enough that we might as well do it here in the front end + lbry.resolve({uri: uri}).then((resolutionInfo) => { + const fee = resolutionInfo.claim.value.stream.metadata.fee; + if (fee === undefined) { + cacheAndResolve(0, true); + } else if (fee.currency == 'LBC') { + cacheAndResolve(fee.amount, true); + } else { + lbryio.getExchangeRates().then(({lbc_usd}) => { + cacheAndResolve(fee.amount / lbc_usd, true); + }); + } + }); + } + + const uriObj = lbryuri.parse(uri); + const name = uriObj.path || uriObj.name; + + lbry.settings_get({allow_cached: true}).then(({is_generous_host}) => { + if (is_generous_host) { + return getCostGenerous(uri); + } + + lighthouse.get_size_for_name(name).then((size) => { + if (size) { + getCost(name, size); + } + else { + getCost(name, null); + } + }, () => { + getCost(name, null); + }); + }); + }); + } + return lbry.costPromiseCache[uri]; } lbry.getMyClaims = function(callback) { lbry.call('get_name_claims', {}, callback); } -lbry.startFile = function(name, callback) { - lbry.call('start_lbry_file', { name: name }, callback); -} - -lbry.stopFile = function(name, callback) { - lbry.call('stop_lbry_file', { name: name }, callback); -} - lbry.removeFile = function(outpoint, deleteTargetFile=true, callback) { this._removedFiles.push(outpoint); this._updateFileInfoSubscribers(outpoint); @@ -393,12 +359,13 @@ lbry.publish = function(params, fileListedCallback, publishedCallback, errorCall returnedPending = true; if (publishedCallback) { - savePendingPublish(params.name); + savePendingPublish({name: params.name, channel_name: params.channel_name}); publishedCallback(true); } if (fileListedCallback) { - savePendingPublish(params.name); + const {name, channel_name} = params; + savePendingPublish({name: params.name, channel_name: params.channel_name}); fileListedCallback(true); } }, 2000); @@ -408,44 +375,6 @@ lbry.publish = function(params, fileListedCallback, publishedCallback, errorCall //}); } -lbry.getVersionInfo = function(callback) { - lbry.call('version', {}, callback); -}; - -lbry.checkNewVersionAvailable = function(callback) { - lbry.call('version', {}, function(versionInfo) { - var ver = versionInfo.lbrynet_version.split('.'); - - var maj = parseInt(ver[0]), - min = parseInt(ver[1]), - patch = parseInt(ver[2]); - - var remoteVer = versionInfo.remote_lbrynet.split('.'); - var remoteMaj = parseInt(remoteVer[0]), - remoteMin = parseInt(remoteVer[1]), - remotePatch = parseInt(remoteVer[2]); - - if (maj < remoteMaj) { - var newVersionAvailable = true; - } else if (maj == remoteMaj) { - if (min < remoteMin) { - var newVersionAvailable = true; - } else if (min == remoteMin) { - var newVersionAvailable = (patch < remotePatch); - } else { - var newVersionAvailable = false; - } - } else { - var newVersionAvailable = false; - } - callback(newVersionAvailable); - }, function(err) { - if (err.fault == 'NoSuchFunction') { - // Really old daemon that can't report a version - callback(true); - } - }); -} lbry.getClientSettings = function() { var outSettings = {}; @@ -458,6 +387,10 @@ lbry.getClientSettings = function() { lbry.getClientSetting = function(setting) { var localStorageVal = localStorage.getItem('setting_' + setting); + if (setting == 'showDeveloperMenu') + { + return true; + } return (localStorageVal === null ? lbry.defaultClientSettings[setting] : JSON.parse(localStorageVal)); } @@ -550,7 +483,7 @@ lbry.stop = function(callback) { lbry.fileInfo = {}; lbry._subscribeIdCount = 0; lbry._fileInfoSubscribeCallbacks = {}; -lbry._fileInfoSubscribeInterval = 5000; +lbry._fileInfoSubscribeInterval = 500000; lbry._balanceSubscribeCallbacks = {}; lbry._balanceSubscribeInterval = 5000; lbry._removedFiles = []; @@ -560,8 +493,9 @@ lbry._updateClaimOwnershipCache = function(claimId) { lbry.getMyClaims((claimInfos) => { lbry._claimIdOwnershipCache[claimId] = !!claimInfos.reduce(function(match, claimInfo) { return match || claimInfo.claim_id == claimId; - }); + }, false); }); + }; lbry._updateFileInfoSubscribers = function(outpoint) { @@ -612,6 +546,7 @@ lbry.fileInfoUnsubscribe = function(outpoint, subscribeId) { delete lbry._fileInfoSubscribeCallbacks[outpoint][subscribeId]; } +lbry._balanceUpdateInterval = null; lbry._updateBalanceSubscribers = function() { lbry.get_balance().then(function(balance) { for (let callback of Object.values(lbry._balanceSubscribeCallbacks)) { @@ -619,8 +554,8 @@ lbry._updateBalanceSubscribers = function() { } }); - if (Object.keys(lbry._balanceSubscribeCallbacks).length) { - setTimeout(() => { + if (!lbry._balanceUpdateInterval && Object.keys(lbry._balanceSubscribeCallbacks).length) { + lbry._balanceUpdateInterval = setInterval(() => { lbry._updateBalanceSubscribers(); }, lbry._balanceSubscribeInterval); } @@ -635,6 +570,9 @@ lbry.balanceSubscribe = function(callback) { lbry.balanceUnsubscribe = function(subscribeId) { delete lbry._balanceSubscribeCallbacks[subscribeId]; + if (lbry._balanceUpdateInterval && !Object.keys(lbry._balanceSubscribeCallbacks).length) { + clearInterval(lbry._balanceUpdateInterval) + } } lbry.showMenuIfNeeded = function() { @@ -646,6 +584,14 @@ lbry.showMenuIfNeeded = function() { sessionStorage.setItem('menuShown', chosenMenu); }; +lbry.getVersionInfo = function() { + return new Promise((resolve, reject) => { + ipcRenderer.once('version-info-received', (event, versionInfo) => { resolve(versionInfo) }); + ipcRenderer.send('version-info-requested'); + }); +} + + /** * 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. @@ -657,14 +603,14 @@ lbry.showMenuIfNeeded = function() { */ lbry.file_list = function(params={}) { return new Promise((resolve, reject) => { - const {name, outpoint} = params; + const {name, channel_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) { + if (outpoint) { const pendingPublish = getPendingPublish({outpoint}); if (pendingPublish) { resolve([pendingPublishToDummyFileInfo(pendingPublish)]); @@ -673,14 +619,8 @@ lbry.file_list = function(params={}) { } lbry.call('file_list', params, (fileInfos) => { - // Remove any pending publications that are now listed in the file manager + removePendingPublishIfNeeded({name, channel_name, outpoint}); - 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); @@ -690,16 +630,60 @@ lbry.file_list = function(params={}) { 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); + for (let {name, channel_name, txid, nout} of claims) { + removePendingPublishIfNeeded({name, channel_name, outpoint: txid + ':' + nout}); + } + const dummyClaims = getPendingPublishes().map(pendingPublishToDummyClaim); resolve([...claims, ...dummyClaims]); - }, reject, reject); + }, reject, reject) }); } +lbry.resolve = function(params={}) { + const claimCacheKey = 'resolve_claim_cache', + claimCache = getSession(claimCacheKey, {}) + return new Promise((resolve, reject) => { + if (!params.uri) { + throw "Resolve has hacked cache on top of it that requires a URI" + } + if (params.uri && claimCache[params.uri] !== undefined) { + resolve(claimCache[params.uri]); + } else { + lbry.call('resolve', params, function(data) { + claimCache[params.uri] = data; + setSession(claimCacheKey, claimCache) + resolve(data) + }, reject) + } + }); +} + +// Adds caching. +lbry.settings_get = function(params={}) { + return new Promise((resolve, reject) => { + if (params.allow_cached) { + const cached = getSession('settings'); + if (cached) { + return resolve(cached); + } + } + + lbry.call('settings_get', {}, (settings) => { + setSession('settings', settings); + resolve(settings); + }); + }); +} + +// lbry.get = function(params={}) { +// return function(params={}) { +// return new Promise((resolve, reject) => { +// jsonrpc.call(lbry.daemonConnectionString, "get", params, resolve, reject, reject); +// }); +// }; +// } + lbry = new Proxy(lbry, { get: function(target, name) { if (name in target) { @@ -708,7 +692,7 @@ lbry = new Proxy(lbry, { return function(params={}) { return new Promise((resolve, reject) => { - jsonrpc.call(lbry.daemonConnectionString, name, [params], resolve, reject, reject); + jsonrpc.call(lbry.daemonConnectionString, name, params, resolve, reject, reject); }); }; } diff --git a/ui/js/lbryio.js b/ui/js/lbryio.js new file mode 100644 index 000000000..20934bbbb --- /dev/null +++ b/ui/js/lbryio.js @@ -0,0 +1,159 @@ +import {getLocal, getSession, setSession, setLocal} from './utils.js'; +import lbry from './lbry.js'; + +const querystring = require('querystring'); + +const lbryio = { + _accessToken: getLocal('accessToken'), + _authenticationPromise: null, + _user : null, + enabled: false +}; + +const CONNECTION_STRING = process.env.LBRY_APP_API_URL ? process.env.LBRY_APP_API_URL : 'https://api.lbry.io/'; +const EXCHANGE_RATE_TIMEOUT = 20 * 60 * 1000; + +lbryio.getExchangeRates = function() { + return new Promise((resolve, reject) => { + const cached = getSession('exchangeRateCache'); + if (!cached || Date.now() - cached.time > EXCHANGE_RATE_TIMEOUT) { + lbryio.call('lbc', 'exchange_rate', {}, 'get', true).then(({lbc_usd, lbc_btc, btc_usd}) => { + const rates = {lbc_usd, lbc_btc, btc_usd}; + setSession('exchangeRateCache', { + rates: rates, + time: Date.now(), + }); + resolve(rates); + }); + } else { + resolve(cached.rates); + } + }); +} + +lbryio.call = function(resource, action, params={}, method='get', evenIfDisabled=false) { // evenIfDisabled is just for development, when we may have some calls working and some not + return new Promise((resolve, reject) => { + if (!lbryio.enabled && !evenIfDisabled && (resource != 'discover' || action != 'list')) { + reject(new Error("LBRY internal API is disabled")) + return + } + + const xhr = new XMLHttpRequest; + + xhr.addEventListener('error', function (event) { + reject(new Error("Something went wrong making an internal API call.")); + }); + + + xhr.addEventListener('timeout', function() { + reject(new Error('XMLHttpRequest connection timed out')); + }); + + xhr.addEventListener('load', function() { + const response = JSON.parse(xhr.responseText); + + if (!response.success) { + if (reject) { + let error = new Error(response.error); + error.xhr = xhr; + reject(error); + } else { + document.dispatchEvent(new CustomEvent('unhandledError', { + detail: { + connectionString: connectionString, + method: action, + params: params, + message: response.error.message, + ... response.error.data ? {data: response.error.data} : {}, + } + })); + } + } else { + resolve(response.data); + } + }); + + // For social media auth: + //const accessToken = localStorage.getItem('accessToken'); + //const fullParams = {...params, ... accessToken ? {access_token: accessToken} : {}}; + + // Temp app ID based auth: + const fullParams = {app_id: lbryio.getAccessToken(), ...params}; + + if (method == 'get') { + xhr.open('get', CONNECTION_STRING + resource + '/' + action + '?' + querystring.stringify(fullParams), true); + xhr.send(); + } else if (method == 'post') { + xhr.open('post', CONNECTION_STRING + resource + '/' + action, true); + xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + xhr.send(querystring.stringify(fullParams)); + } + }); +}; + +lbryio.getAccessToken = () => { + return getLocal('accessToken'); +} + +lbryio.setAccessToken = (token) => { + setLocal('accessToken', token) +} + +lbryio.authenticate = function() { + if (!lbryio.enabled) { + return new Promise((resolve, reject) => { + resolve({ + ID: 1, + HasVerifiedEmail: true + }) + }) + } + if (lbryio._authenticationPromise === null) { + lbryio._authenticationPromise = new Promise((resolve, reject) => { + lbry.status().then((response) => { + + let installation_id = response.installation_id; + + function setCurrentUser() { + lbryio.call('user', 'me').then((data) => { + lbryio.user = data + resolve(data) + }).catch(function(err) { + lbryio.setAccessToken(null); + if (!getSession('reloadedOnFailedAuth')) { + setSession('reloadedOnFailedAuth', true) + window.location.reload(); + } else { + reject(err); + } + }) + } + + if (!lbryio.getAccessToken()) { + lbryio.call('user', 'new', { + language: 'en', + app_id: installation_id, + }, 'post').then(function(responseData) { + if (!responseData.ID) { + reject(new Error("Received invalid authentication response.")); + } + lbryio.setAccessToken(installation_id) + setCurrentUser() + }).catch(function(error) { + /* + until we have better error code format, assume all errors are duplicate application id + if we're wrong, this will be caught by later attempts to make a valid call + */ + lbryio.setAccessToken(installation_id) + setCurrentUser() + }) + } else { + setCurrentUser() + } + }).catch(reject); + }); + } + return lbryio._authenticationPromise; +} + +export default lbryio; diff --git a/ui/js/lbryuri.js b/ui/js/lbryuri.js new file mode 100644 index 000000000..2712b812a --- /dev/null +++ b/ui/js/lbryuri.js @@ -0,0 +1,169 @@ +const CHANNEL_NAME_MIN_LEN = 4; +const CLAIM_ID_MAX_LEN = 40; + +const lbryuri = {}; + +/** + * Parses a LBRY name into its component parts. Throws errors with user-friendly + * messages for invalid names. + * + * N.B. that "name" indicates the value in the name position of the URI. For + * claims for channel content, this will actually be the channel name, and + * the content name is in the path (e.g. lbry://@channel/content) + * + * In most situations, you'll want to use the contentName and channelName keys + * and ignore the name key. + * + * Returns a dictionary with keys: + * - name (string): The value in the "name" position in the URI. Note that this + * could be either content name or channel name; see above. + * - path (string, if persent) + * - claimSequence (int, if present) + * - bidPosition (int, if present) + * - claimId (string, if present) + * - isChannel (boolean) + * - contentName (string): For anon claims, the name; for channel claims, the path + * - channelName (string, if present): Channel name without @ + */ +lbryuri.parse = function(uri, requireProto=false) { + // Break into components. Empty sub-matches are converted to null + const componentsRegex = new RegExp( + '^((?:lbry:\/\/)?)' + // protocol + '([^:$#/]*)' + // name (stops at the first separator or end) + '([:$#]?)([^/]*)' + // modifier separator, modifier (stops at the first path separator or end) + '(/?)(.*)' // path separator, path + ); + const [proto, name, modSep, modVal, pathSep, path] = componentsRegex.exec(uri).slice(1).map(match => match || null); + + let contentName; + + // Validate protocol + if (requireProto && !proto) { + throw new Error('LBRY URIs must include a protocol prefix (lbry://).'); + } + + // Validate and process name + if (!name) { + throw new Error('URI does not include name.'); + } + + const isChannel = name.startsWith('@'); + const channelName = isChannel ? name.slice(1) : name; + + if (isChannel) { + if (!channelName) { + throw new Error('No channel name after @.'); + } + + if (channelName.length < CHANNEL_NAME_MIN_LEN) { + throw new Error(`Channel names must be at least ${CHANNEL_NAME_MIN_LEN} characters.`); + } + + contentName = path; + } + + const nameBadChars = (channelName || name).match(/[^A-Za-z0-9-]/g); + if (nameBadChars) { + throw new Error(`Invalid character${nameBadChars.length == 1 ? '' : 's'} in name: ${nameBadChars.join(', ')}.`); + } + + // Validate and process modifier (claim ID, bid position or claim sequence) + let claimId, claimSequence, bidPosition; + if (modSep) { + if (!modVal) { + throw new Error(`No modifier provided after separator ${modSep}.`); + } + + if (modSep == '#') { + claimId = modVal; + } else if (modSep == ':') { + claimSequence = modVal; + } else if (modSep == '$') { + bidPosition = modVal; + } + } + + if (claimId && (claimId.length > CLAIM_ID_MAX_LEN || !claimId.match(/^[0-9a-f]+$/))) { + throw new Error(`Invalid claim ID ${claimId}.`); + } + + if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]*$/)) { + throw new Error('Claim sequence must be a number.'); + } + + if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]*$/)) { + throw new Error('Bid position must be a number.'); + } + + // Validate and process path + if (path) { + if (!isChannel) { + throw new Error('Only channel URIs may have a path.'); + } + + const pathBadChars = path.match(/[^A-Za-z0-9-]/g); + if (pathBadChars) { + throw new Error(`Invalid character${count == 1 ? '' : 's'} in path: ${nameBadChars.join(', ')}`); + } + + contentName = path; + } else if (pathSep) { + throw new Error('No path provided after /'); + } + + return { + name, path, isChannel, + ... contentName ? {contentName} : {}, + ... channelName ? {channelName} : {}, + ... claimSequence ? {claimSequence: parseInt(claimSequence)} : {}, + ... bidPosition ? {bidPosition: parseInt(bidPosition)} : {}, + ... claimId ? {claimId} : {}, + ... path ? {path} : {}, + }; +} + +/** + * Takes an object in the same format returned by lbryuri.parse() and builds a URI. + * + * The channelName key will accept names with or without the @ prefix. + */ +lbryuri.build = function(uriObj, includeProto=true, allowExtraProps=false) { + let {name, claimId, claimSequence, bidPosition, path, contentName, channelName} = uriObj; + + if (channelName) { + const channelNameFormatted = channelName.startsWith('@') ? channelName : '@' + channelName; + if (!name) { + name = channelNameFormatted; + } else if (name !== channelNameFormatted) { + throw new Error('Received a channel content URI, but name and channelName do not match. "name" represents the value in the name position of the URI (lbry://name...), which for channel content will be the channel name. In most cases, to construct a channel URI you should just pass channelName and contentName.'); + } + } + + if (contentName) { + if (!name) { + name = contentName; + } else if (!path) { + path = contentName; + } + if (path && path !== contentName) { + throw new Error('path and contentName do not match. Only one is required; most likely you wanted contentName.'); + } + } + + return (includeProto ? 'lbry://' : '') + name + + (claimId ? `#${claimId}` : '') + + (claimSequence ? `:${claimSequence}` : '') + + (bidPosition ? `\$${bidPosition}` : '') + + (path ? `/${path}` : ''); + +} + +/* Takes a parseable LBRY URI and converts it to standard, canonical format (currently this just + * consists of adding the lbry:// prefix if needed) */ +lbryuri.normalize= function(uri) { + const {name, path, bidPosition, claimSequence, claimId} = lbryuri.parse(uri); + return lbryuri.build({name, path, claimSequence, bidPosition, claimId}); +} + +window.lbryuri = lbryuri; +export default lbryuri; diff --git a/ui/js/lighthouse.js b/ui/js/lighthouse.js index a8b60f0fa..5ca4ef038 100644 --- a/ui/js/lighthouse.js +++ b/ui/js/lighthouse.js @@ -1,12 +1,12 @@ import lbry from './lbry.js'; import jsonrpc from './jsonrpc.js'; -const queryTimeout = 5000; -const maxQueryTries = 5; +const queryTimeout = 3000; +const maxQueryTries = 2; const defaultServers = [ - 'http://lighthouse4.lbry.io:50005', - 'http://lighthouse5.lbry.io:50005', - 'http://lighthouse6.lbry.io:50005', + 'http://lighthouse7.lbry.io:50005', + 'http://lighthouse8.lbry.io:50005', + 'http://lighthouse9.lbry.io:50005', ]; const path = '/'; @@ -20,12 +20,9 @@ function getServers() { } function call(method, params, callback, errorCallback) { - if (connectTryNum > maxQueryTries) { - if (connectFailedCallback) { - connectFailedCallback(); - } else { - throw new Error(`Could not connect to Lighthouse server. Last server attempted: ${server}`); - } + if (connectTryNum >= maxQueryTries) { + errorCallback(new Error(`Could not connect to Lighthouse server. Last server attempted: ${server}`)); + return; } /** @@ -48,7 +45,7 @@ function call(method, params, callback, errorCallback) { }, () => { connectTryNum++; call(method, params, callback, errorCallback); - }); + }, queryTimeout); } const lighthouse = new Proxy({}, { diff --git a/ui/js/main.js b/ui/js/main.js index f00c49a69..610ca8594 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -1,9 +1,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import lbry from './lbry.js'; +import lbryio from './lbryio.js'; import lighthouse from './lighthouse.js'; import App from './app.js'; import SplashScreen from './component/splash.js'; +import SnackBar from './component/snack-bar.js'; +import {AuthOverlay} from './component/auth.js'; const {remote} = require('electron'); const contextMenu = remote.require('./menu/context-menu'); @@ -16,31 +19,24 @@ window.addEventListener('contextmenu', (event) => { event.preventDefault(); }); -var init = function() { +let init = function() { window.lbry = lbry; window.lighthouse = lighthouse; + let canvas = document.getElementById('canvas'); + + lbry.connect().then(function(isConnected) { + lbryio.authenticate() //start auth process as soon as soon as we can get an install ID + }) + + function onDaemonReady() { + window.sessionStorage.setItem('loaded', 'y'); //once we've made it here once per session, we don't need to show splash again + ReactDOM.render(
{ lbryio.enabled ? : '' }
, canvas) + } - var canvas = document.getElementById('canvas'); if (window.sessionStorage.getItem('loaded') == 'y') { - ReactDOM.render(, canvas) + onDaemonReady(); } else { - ReactDOM.render( - { - if (balance <= 0) { - window.location.href = '?claim'; - } else { - ReactDOM.render(, canvas); - } - }); - } else { - ReactDOM.render(, canvas); - } - }}/>, - canvas - ); + ReactDOM.render(, canvas); } }; diff --git a/ui/js/page/claim_code.js b/ui/js/page/claim_code.js deleted file mode 100644 index 7a9976824..000000000 --- a/ui/js/page/claim_code.js +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; -import lbry from '../lbry.js'; -import Modal from '../component/modal.js'; -import {Link} from '../component/link.js'; - -var claimCodeContentStyle = { - display: 'inline-block', - textAlign: 'left', - width: '600px', -}, claimCodeLabelStyle = { - display: 'inline-block', - cursor: 'default', - width: '130px', - textAlign: 'right', - marginRight: '6px', -}; - -var ClaimCodePage = React.createClass({ - getInitialState: function() { - return { - submitting: false, - modal: null, - referralCredits: null, - activationCredits: null, - failureReason: null, - } - }, - handleSubmit: function(event) { - if (typeof event !== 'undefined') { - event.preventDefault(); - } - - if (!this.refs.code.value) { - this.setState({ - modal: 'missingCode', - }); - return; - } else if (!this.refs.email.value) { - this.setState({ - modal: 'missingEmail', - }); - return; - } - - this.setState({ - submitting: true, - }); - - lbry.getUnusedAddress((address) => { - var code = this.refs.code.value; - var email = this.refs.email.value; - - var xhr = new XMLHttpRequest; - xhr.addEventListener('load', () => { - var response = JSON.parse(xhr.responseText); - - if (response.success) { - this.setState({ - modal: 'codeRedeemed', - referralCredits: response.referralCredits, - activationCredits: response.activationCredits, - }); - } else { - this.setState({ - submitting: false, - modal: 'codeRedeemFailed', - failureReason: response.reason, - }); - } - }); - - xhr.addEventListener('error', () => { - this.setState({ - submitting: false, - modal: 'couldNotConnect', - }); - }); - - xhr.open('POST', 'https://invites.lbry.io', true); - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr.send('code=' + encodeURIComponent(code) + '&address=' + encodeURIComponent(address) + - '&email=' + encodeURIComponent(email)); - }); - }, - handleSkip: function() { - this.setState({ - modal: 'skipped', - }); - }, - handleFinished: function() { - localStorage.setItem('claimCodeDone', true); - window.location = '?home'; - }, - closeModal: function() { - this.setState({ - modal: null, - }); - }, - render: function() { - return ( -
-
-
-

Claim your beta invitation code

-
-

Thanks for beta testing LBRY! Enter your invitation code and email address below to receive your initial - LBRY credits.

-

You will be added to our mailing list (if you're not already on it) and will be eligible for future rewards for beta testers.

-
-
-
-
-
-
- - - -
-
-
- - Please enter an invitation code or choose "Skip." - - - Please enter an email address or choose "Skip." - - - {this.state.failureReason} - - - Your invite code has been redeemed. { ' ' } - {this.state.referralCredits > 0 - ? `You have also earned ${referralCredits} credits from referrals. A total of ${activationCredits + referralCredits} - will be added to your balance shortly.` - : (this.state.activationCredits > 0 - ? `${this.state.activationCredits} credits will be added to your balance shortly.` - : 'The credits will be added to your balance shortly.')} - - - Welcome to LBRY! You can visit the Wallet page to redeem an invite code at any time. - - -

LBRY couldn't connect to our servers to confirm your invitation code. Please check your internet connection.

- If you continue to have problems, you can still browse LBRY and visit the Settings page to redeem your code later. -
-
- ); - } -}); - -export default ClaimCodePage; diff --git a/ui/js/page/developer.js b/ui/js/page/developer.js index 93eb1cc11..377204852 100644 --- a/ui/js/page/developer.js +++ b/ui/js/page/developer.js @@ -1,6 +1,6 @@ import lbry from '../lbry.js'; import React from 'react'; -import FormField from '../component/form.js'; +import {FormField} from '../component/form.js'; import {Link} from '../component/link.js'; const fs = require('fs'); diff --git a/ui/js/page/discover.js b/ui/js/page/discover.js index 762c55d3c..d522a99f8 100644 --- a/ui/js/page/discover.js +++ b/ui/js/page/discover.js @@ -1,168 +1,62 @@ import React from 'react'; -import lbry from '../lbry.js'; -import lighthouse from '../lighthouse.js'; -import {FileTile} from '../component/file-tile.js'; -import {Link} from '../component/link.js'; +import lbryio from '../lbryio.js'; +import {FileTile, FileTileStream} from '../component/file-tile.js'; import {ToolTip} from '../component/tooltip.js'; -import {BusyMessage} from '../component/common.js'; -var fetchResultsStyle = { - color: '#888', - textAlign: 'center', - fontSize: '1.2em' - }; +const communityCategoryToolTipText = ('Community Content is a public space where anyone can share content with the ' + + 'rest of the LBRY community. Bid on the names "one," "two," "three," "four" and ' + +'"five" to put your content here!'); -var SearchActive = React.createClass({ +let FeaturedCategory = React.createClass({ render: function() { - return ( -
- -
- ); + return (
+ { this.props.category ? +

{this.props.category} + { this.props.category.match(/^community/i) ? + + : '' }

+ : '' } + { this.props.names.map((name) => { return }) } +
) } -}); +}) -var searchNoResultsStyle = { - textAlign: 'center' -}, searchNoResultsMessageStyle = { - fontStyle: 'italic', - marginRight: '5px' -}; - -var SearchNoResults = React.createClass({ - render: function() { - return ( -
- No one has checked anything in for {this.props.query} yet. - -
- ); - } -}); - -var SearchResults = React.createClass({ - render: function() { - var rows = [], - seenNames = {}; //fix this when the search API returns claim IDs - this.props.results.forEach(function({name, value}) { - if (!seenNames[name]) { - seenNames[name] = name; - rows.push( - - ); - } - }); - return ( -
{rows}
- ); - } -}); - -var featuredContentLegendStyle = { - fontSize: '12px', - color: '#aaa', - verticalAlign: '15%', -}; - -var FeaturedContent = React.createClass({ +let DiscoverPage = React.createClass({ getInitialState: function() { return { - featuredNames: [], + featuredUris: {}, + failed: false }; }, componentWillMount: function() { - lbry.getFeaturedDiscoverNames().then((featuredNames) => { - this.setState({ featuredNames: featuredNames }); - }); - }, - render: function() { - const toolTipText = ('Community Content is a public space where anyone can share content with the ' + - 'rest of the LBRY community. Bid on the names "one," "two," "three," "four" and ' + - '"five" to put your content here!'); - - return ( -
-
-

Featured Content

- { this.state.featuredNames.map((name) => { return }) } -
-
-

- Community Content - -

- - - - - -
-
- ); - } -}); - -var DiscoverPage = React.createClass({ - userTypingTimer: null, - - componentDidUpdate: function() { - if (this.props.query != this.state.query) - { - this.handleSearchChanged(this.props.query); - } - }, - - componentWillReceiveProps: function(nextProps, nextState) { - if (nextProps.query != nextState.query) - { - this.handleSearchChanged(nextProps.query); - } - }, - - handleSearchChanged: function(query) { - this.setState({ - searching: true, - query: query, - }); - - lighthouse.search(query).then(this.searchCallback); - }, - - componentWillMount: function() { - document.title = "Discover"; - if (this.props.query) { - // Rendering with a query already typed - this.handleSearchChanged(this.props.query); - } - }, - - getInitialState: function() { - return { - results: [], - query: this.props.query, - searching: ('query' in this.props) && (this.props.query.length > 0) - }; - }, - - searchCallback: function(results) { - if (this.state.searching) //could have canceled while results were pending, in which case nothing to do - { + lbryio.call('discover', 'list', { version: "early-access" } ).then(({Categories, Uris}) => { + let featuredUris = {} + Categories.forEach((category) => { + if (Uris[category] && Uris[category].length) { + featuredUris[category] = Uris[category] + } + }) + this.setState({ featuredUris: featuredUris }); + }, () => { this.setState({ - results: results, - searching: false //multiple searches can be out, we're only done if we receive one we actually care about - }); - } + failed: true + }) + }); }, - render: function() { - return ( -
- { this.state.searching ? : null } - { !this.state.searching && this.props.query && this.state.results.length ? : null } - { !this.state.searching && this.props.query && !this.state.results.length ? : null } - { !this.props.query && !this.state.searching ? : null } -
- ); + return
{ + this.state.failed ? +
Failed to load landing content.
: +
+ { + Object.keys(this.state.featuredUris).map((category) => { + return this.state.featuredUris[category].length ? + : + ''; + }) + } +
+ }
; } }); diff --git a/ui/js/page/file-list.js b/ui/js/page/file-list.js index 8134be11f..71f8e2fc2 100644 --- a/ui/js/page/file-list.js +++ b/ui/js/page/file-list.js @@ -1,11 +1,24 @@ import React from 'react'; import lbry from '../lbry.js'; +import lbryuri from '../lbryuri.js'; import {Link} from '../component/link.js'; -import FormField from '../component/form.js'; +import {FormField} from '../component/form.js'; +import {SubHeader} from '../component/header.js'; import {FileTileStream} from '../component/file-tile.js'; +import rewards from '../rewards.js'; +import lbryio from '../lbryio.js'; import {BusyMessage, Thumbnail} from '../component/common.js'; +export let FileListNav = React.createClass({ + render: function() { + return ; + } +}); + export let FileListDownloaded = React.createClass({ _isMounted: false, @@ -16,7 +29,6 @@ export let FileListDownloaded = React.createClass({ }, componentDidMount: function() { this._isMounted = true; - document.title = "Downloaded Files"; lbry.claim_list_mine().then((myClaimInfos) => { if (!this._isMounted) { return; } @@ -31,26 +43,24 @@ export let FileListDownloaded = React.createClass({ }); }); }, + componentWillUnmount: function() { + this._isMounted = false; + }, render: function() { + let content = ""; if (this.state.fileInfos === null) { - return ( -
- -
- ); + content = ; } else if (!this.state.fileInfos.length) { - return ( -
- You haven't downloaded anything from LBRY yet. Go ! -
- ); + content = You haven't downloaded anything from LBRY yet. Go !; } else { - return ( -
- -
- ); + content = ; } + return ( +
+ + {content} +
+ ); } }); @@ -62,9 +72,22 @@ export let FileListPublished = React.createClass({ fileInfos: null, }; }, + _requestPublishReward: function() { + lbryio.call('reward', 'list', {}).then(function(userRewards) { + //already rewarded + if (userRewards.filter(function (reward) { + return reward.RewardType == rewards.TYPE_FIRST_PUBLISH && reward.TransactionID; + }).length) { + return; + } + else { + rewards.claimReward(rewards.TYPE_FIRST_PUBLISH).catch(() => {}) + } + }, () => {}); + }, componentDidMount: function () { this._isMounted = true; - document.title = "Published Files"; + this._requestPublishReward(); lbry.claim_list_mine().then((claimInfos) => { if (!this._isMounted) { return; } @@ -79,28 +102,26 @@ export let FileListPublished = React.createClass({ }); }); }, + componentWillUnmount: function() { + this._isMounted = false; + }, render: function () { + let content = null; if (this.state.fileInfos === null) { - return ( -
- -
- ); + content = ; } else if (!this.state.fileInfos.length) { - return ( -
- You haven't published anything to LBRY yet. Try ! -
- ); + content = You haven't published anything to LBRY yet. Try !; } else { - return ( -
- -
- ); + content = ; } + return ( +
+ + {content} +
+ ); } }); @@ -160,14 +181,24 @@ export let FileList = React.createClass({ seenUris = {}; const fileInfosSorted = this._sortFunctions[this.state.sortBy](this.props.fileInfos); - for (let {name, outpoint, metadata} of fileInfosSorted) { - if (!metadata || seenUris[name]) { + for (let {outpoint, name, channel_name, metadata, mime_type, claim_id, has_signature, signature_is_valid} of fileInfosSorted) { + if (seenUris[name] || !claim_id) { continue; } + let streamMetadata; + if (metadata) { + streamMetadata = metadata.stream.metadata; + } else { + streamMetadata = null; + } + + + const uri = lbryuri.build({contentName: name, channelName: channel_name}); seenUris[name] = true; - content.push(); + content.push(); } return ( diff --git a/ui/js/page/help.js b/ui/js/page/help.js index 632c3abd0..d6a28ae99 100644 --- a/ui/js/page/help.js +++ b/ui/js/page/help.js @@ -3,6 +3,7 @@ import React from 'react'; import lbry from '../lbry.js'; import {Link} from '../component/link.js'; +import {SettingsNav} from './settings.js'; import {version as uiVersion} from 'json!../../package.json'; var HelpPage = React.createClass({ @@ -24,9 +25,6 @@ var HelpPage = React.createClass({ }); }); }, - componentDidMount: function() { - document.title = "Help"; - }, render: function() { let ver, osName, platform, newVerLink; if (this.state.versionInfo) { @@ -49,58 +47,71 @@ var HelpPage = React.createClass({ } return ( -
+
+
-

Read the FAQ

-

Our FAQ answers many common questions.

-

+
+

Read the FAQ

+
+
+

Our FAQ answers many common questions.

+

+
-

Get Live Help

-

- Live help is available most hours in the #help channel of our Slack chat room. -

-

- -

+
+

Get Live Help

+
+
+

+ Live help is available most hours in the #help channel of our Slack chat room. +

+

+ +

+
-

Report a Bug

-

Did you find something wrong?

-

-
Thanks! LBRY is made by its users.
+

Report a Bug

+
+

Did you find something wrong?

+

+
Thanks! LBRY is made by its users.
+
{!ver ? null :
-

About

- {ver.lbrynet_update_available || ver.lbryum_update_available ? -

A newer version of LBRY is available.

- :

Your copy of LBRY is up to date.

- } - - - - - - - - - - - - - - - - - - - - - - - -
daemon (lbrynet){ver.lbrynet_version}
wallet (lbryum){ver.lbryum_version}
interface{uiVersion}
Platform{platform}
Installation ID{this.state.lbryId}
+

About

+
+ {ver.lbrynet_update_available || ver.lbryum_update_available ? +

A newer version of LBRY is available.

+ :

Your copy of LBRY is up to date.

+ } + + + + + + + + + + + + + + + + + + + + + + + +
daemon (lbrynet){ver.lbrynet_version}
wallet (lbryum){ver.lbryum_version}
interface{uiVersion}
Platform{platform}
Installation ID{this.state.lbryId}
+
}
diff --git a/ui/js/page/publish.js b/ui/js/page/publish.js index 019783587..03583136b 100644 --- a/ui/js/page/publish.js +++ b/ui/js/page/publish.js @@ -1,16 +1,19 @@ import React from 'react'; import lbry from '../lbry.js'; -import FormField from '../component/form.js'; +import {FormField, FormRow} from '../component/form.js'; import {Link} from '../component/link.js'; +import rewards from '../rewards.js'; +import lbryio from '../lbryio.js'; import Modal from '../component/modal.js'; var PublishPage = React.createClass({ - _requiredFields: ['name', 'bid', 'meta_title', 'meta_author', 'meta_license', 'meta_description'], + _requiredFields: ['meta_title', 'name', 'bid', 'tos_agree'], _updateChannelList: function(channel) { // Calls API to update displayed list of channels. If a channel name is provided, will select // that channel at the same time (used immediately after creating a channel) lbry.channel_list_mine().then((channels) => { + rewards.claimReward(rewards.TYPE_FIRST_CHANNEL).then(() => {}, () => {}) this.setState({ channels: channels, ... channel ? {channel} : {} @@ -26,19 +29,23 @@ var PublishPage = React.createClass({ submitting: true, }); - var checkFields = this._requiredFields.slice(); + let checkFields = this._requiredFields; if (!this.state.myClaimExists) { - checkFields.push('file'); + checkFields.unshift('file'); } - var missingFieldFound = false; + let missingFieldFound = false; for (let fieldName of checkFields) { - var field = this.refs[fieldName]; - if (field.getValue() === '') { - field.warnRequired(); - if (!missingFieldFound) { - field.focus(); - missingFieldFound = true; + const field = this.refs[fieldName]; + if (field) { + if (field.getValue() === '' || field.getValue() === false) { + field.showRequiredError(); + if (!missingFieldFound) { + field.focus(); + missingFieldFound = true; + } + } else { + field.clearError(); } } } @@ -60,14 +67,16 @@ var PublishPage = React.createClass({ var metadata = {}; } - for (let metaField of ['title', 'author', 'description', 'thumbnail', 'license', 'license_url', 'language', 'nsfw']) { + for (let metaField of ['title', 'description', 'thumbnail', 'license', 'license_url', 'language']) { var value = this.refs['meta_' + metaField].getValue(); if (value !== '') { metadata[metaField] = value; } } - var licenseUrl = this.refs.meta_license_url.getValue(); + metadata.nsfw = Boolean(parseInt(!!this.refs.meta_nsfw.getValue())); + + const licenseUrl = this.refs.meta_license_url.getValue(); if (licenseUrl) { metadata.license_url = licenseUrl; } @@ -81,9 +90,9 @@ var PublishPage = React.createClass({ }; if (this.refs.file.getValue() !== '') { - publishArgs.file_path = this.refs.file.getValue(); + publishArgs.file_path = this.refs.file.getValue(); } - + lbry.publish(publishArgs, (message) => { this.handlePublishStarted(); }, null, (error) => { @@ -110,17 +119,18 @@ var PublishPage = React.createClass({ channels: null, rawName: '', name: '', - bid: '', + bid: 10, + hasFile: false, feeAmount: '', feeCurrency: 'USD', channel: 'anonymous', newChannelName: '@', - newChannelBid: '', - nameResolved: false, + newChannelBid: 10, + nameResolved: null, + myClaimExists: null, topClaimValue: 0.0, myClaimValue: 0.0, myClaimMetadata: null, - myClaimExists: null, copyrightNotice: '', otherLicenseDescription: '', otherLicenseUrl: '', @@ -138,7 +148,7 @@ var PublishPage = React.createClass({ }); }, handlePublishStartedConfirmed: function() { - window.location = "?published"; + window.location.href = "?published"; }, handlePublishError: function(error) { this.setState({ @@ -161,57 +171,63 @@ var PublishPage = React.createClass({ } if (!lbry.nameIsValid(rawName, false)) { - this.refs.name.showAdvice('LBRY names must contain only letters, numbers and dashes.'); + this.refs.name.showError('LBRY names must contain only letters, numbers and dashes.'); return; } + const name = rawName.toLowerCase(); this.setState({ rawName: rawName, + name: name, + nameResolved: null, + myClaimExists: null, }); - var name = rawName.toLowerCase(); - - lbry.resolveName(name, (info) => { - if (name != this.refs.name.getValue().toLowerCase()) { + lbry.getMyClaim(name, (myClaimInfo) => { + if (name != this.state.name) { // A new name has been typed already, so bail return; } - if (!info) { + this.setState({ + myClaimExists: !!myClaimInfo, + }); + lbry.resolve({uri: name}).then((claimInfo) => { + if (name != this.state.name) { + return; + } + + if (!claimInfo) { + this.setState({ + nameResolved: false, + }); + } else { + const topClaimIsMine = (myClaimInfo && myClaimInfo.claim.amount >= claimInfo.claim.amount); + const newState = { + nameResolved: true, + topClaimValue: parseFloat(claimInfo.claim.amount), + myClaimExists: !!myClaimInfo, + myClaimValue: myClaimInfo ? parseFloat(myClaimInfo.claim.amount) : null, + myClaimMetadata: myClaimInfo ? myClaimInfo.value : null, + topClaimIsMine: topClaimIsMine, + }; + + if (topClaimIsMine) { + newState.bid = myClaimInfo.claim.amount; + } else if (this.state.myClaimMetadata) { + // Just changed away from a name we have a claim on, so clear pre-fill + newState.bid = ''; + } + + this.setState(newState); + } + }, () => { // Assume an error means the name is available this.setState({ name: name, nameResolved: false, myClaimExists: false, }); - } else { - lbry.getMyClaim(name, (myClaimInfo) => { - lbry.getClaimInfo(name, (claimInfo) => { - if (name != this.refs.name.getValue()) { - return; - } - - const topClaimIsMine = (myClaimInfo && myClaimInfo.amount >= claimInfo.amount); - const newState = { - name: name, - nameResolved: true, - topClaimValue: parseFloat(claimInfo.amount), - myClaimExists: !!myClaimInfo, - myClaimValue: myClaimInfo ? parseFloat(myClaimInfo.amount) : null, - myClaimMetadata: myClaimInfo ? myClaimInfo.value : null, - topClaimIsMine: topClaimIsMine, - }; - - if (topClaimIsMine) { - newState.bid = myClaimInfo.amount; - } else if (this.state.myClaimMetadata) { - // Just changed away from a name we have a claim on, so clear pre-fill - newState.bid = ''; - } - - this.setState(newState); - }); - }); - } + }); }); }, handleBidChange: function(event) { @@ -234,7 +250,7 @@ var PublishPage = React.createClass({ isFee: feeEnabled }); }, - handeLicenseChange: function(event) { + handleLicenseChange: function(event) { var licenseType = event.target.options[event.target.selectedIndex].getAttribute('data-license-type'); var newState = { copyrightChosen: licenseType == 'copyright', @@ -242,8 +258,7 @@ var PublishPage = React.createClass({ }; if (licenseType == 'copyright') { - var author = this.refs.meta_author.getValue(); - newState.copyrightNotice = 'Copyright ' + (new Date().getFullYear()) + (author ? ' ' + author : ''); + newState.copyrightNotice = 'All rights reserved.' } this.setState(newState); @@ -274,8 +289,10 @@ var PublishPage = React.createClass({ const newChannelName = (event.target.value.startsWith('@') ? event.target.value : '@' + event.target.value); if (newChannelName.length > 1 && !lbry.nameIsValid(newChannelName.substr(1), false)) { - this.refs.newChannelName.showAdvice('LBRY channel names must contain only letters, numbers and dashes.'); + this.refs.newChannelName.showError('LBRY channel names must contain only letters, numbers and dashes.'); return; + } else { + this.refs.newChannelName.clearError() } this.setState({ @@ -287,9 +304,14 @@ var PublishPage = React.createClass({ newChannelBid: event.target.value, }); }, + handleTOSChange: function(event) { + this.setState({ + TOSAgreed: event.target.checked, + }); + }, handleCreateChannelClick: function (event) { if (this.state.newChannelName.length < 5) { - this.refs.newChannelName.showAdvice('LBRY channel names must be at least 4 characters in length.'); + this.refs.newChannelName.showError('LBRY channel names must be at least 4 characters in length.'); return; } @@ -308,7 +330,7 @@ var PublishPage = React.createClass({ }, 5000); }, (error) => { // TODO: better error handling - this.refs.newChannelName.showAdvice('Unable to create channel due to an internal error.'); + this.refs.newChannelName.showError('Unable to create channel due to an internal error.'); this.setState({ creatingChannel: false, }); @@ -326,167 +348,207 @@ var PublishPage = React.createClass({ componentWillMount: function() { this._updateChannelList(); }, - componentDidMount: function() { - document.title = "Publish"; - }, componentDidUpdate: function() { }, - // Also getting a type warning here too + onFileChange: function() { + if (this.refs.file.getValue()) { + this.setState({ hasFile: true }) + } else { + this.setState({ hasFile: false }) + } + }, + getNameBidHelpText: function() { + if (!this.state.name) { + return "Select a URL for this publish."; + } else if (this.state.nameResolved === false) { + return "This URL is unused."; + } else if (this.state.myClaimExists) { + return "You have already used this URL. Publishing to it again will update your previous publish." + } else if (this.state.topClaimValue) { + return A deposit of at least {this.state.topClaimValue} {this.state.topClaimValue == 1 ? 'credit ' : 'credits '} + is required to win {this.state.name}. However, you can still get a permanent URL for any amount. + } else { + return ''; + } + }, + closeModal: function() { + this.setState({ + modal: null, + }); + }, render: function() { if (this.state.channels === null) { return null; } + const lbcInputHelp = "This LBC remains yours and the deposit can be undone at any time." + return ( -
+
-

LBRY Name

-
- lbry:// - { - (!this.state.name ? '' : - (! this.state.nameResolved ? The name {this.state.name} is available. - : (this.state.myClaimExists ? You already have a claim on the name {this.state.name}. You can use this page to update your claim. - : The name {this.state.name} is currently claimed for {this.state.topClaimValue} {this.state.topClaimValue == 1 ? 'credit' : 'credits'}.))) - } -
What LBRY name would you like to claim for this file?
+
+

Content

+
+ What are you publishing? +
+
+
+ +
+ { !this.state.hasFile ? '' : +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + +
+
+ + {/* */} + + + +
+
} +
+ +
+
+

Access

+
+ How much does this content cost? +
+
+
+
+ +
+ { this.handleFeePrefChange(false) } } defaultChecked={!this.state.isFee} /> + { this.handleFeePrefChange(true) } } defaultChecked={this.state.isFee} /> + + + + + + + { this.state.isFee ? +
+ If you choose to price this content in dollars, the number of credits charged will be adjusted based on the value of LBRY credits at the time of purchase. +
: '' } + + + + + + + + + + + + + + {this.state.copyrightChosen + ? + : null} + {this.state.otherLicenseChosen ? + + : null} + {this.state.otherLicenseChosen ? + + : null}
-

Channel

-
- +
+

Identity

+
+ Who created this content? +
+
+
+ {this.state.channels.map(({name}) => )} - - - {this.state.channel == 'new' - ?
- - - -
- : null} -
What channel would you like to publish this file under?
+ +
-
- -
-

Choose File

- - { this.state.myClaimExists ?
If you don't choose a file, the file from your existing claim will be used.
: null } -
- -
-

Bid Amount

-
- Credits -
How much would you like to bid for this name? - { !this.state.nameResolved ? Since this name is not currently resolved, you may bid as low as you want, but higher bids help prevent others from claiming your name. - : (this.state.topClaimIsMine ? You currently control this name with a bid of {this.state.myClaimValue} {this.state.myClaimValue == 1 ? 'credit' : 'credits'}. - : (this.state.myClaimExists ? You have a non-winning bid on this name for {this.state.myClaimValue} {this.state.myClaimValue == 1 ? 'credit' : 'credits'}. - To control this name, you'll need to increase your bid to more than {this.state.topClaimValue} {this.state.topClaimValue == 1 ? 'credit' : 'credits'}. - : You must bid over {this.state.topClaimValue} {this.state.topClaimValue == 1 ? 'credit' : 'credits'} to claim this name.)) } -
-
-
- -
-

Fee

-
- - -
-

How much would you like to charge for this file?

- If you choose to price this content in dollars, the number of credits charged will be adjusted based on the value of LBRY credits at the time of purchase. -
-
-
- - -
-

Your Content

- -
- -
-
- -
-
- - - - - - - - - - - - -
- {this.state.copyrightChosen - ?
- + {this.state.channel == 'new' ? +
+ { this.refs.newChannelName = newChannelName }} + value={this.state.newChannelName} /> + +
+ +
: null} - {this.state.otherLicenseChosen - ?
- -
- : null} - {this.state.otherLicenseChosen - ?
- -
- : null} - -
- - - - - - - - - -
-
- -
-
- -
+
+
+

Address

+
Where should this content permanently reside? .
+
+
+ +
+ { this.state.rawName ? +
+ +
: '' } +
-

Additional Content Information (Optional)

-
- +
+

Terms of Service

+
+
+ I agree to the + } type="checkbox" name="tos_agree" ref={(field) => { this.refs.tos_agree = field }} onChange={this.handleTOSChange} />
- +
@@ -494,7 +556,7 @@ var PublishPage = React.createClass({

Your file has been published to LBRY at the address lbry://{this.state.name}!

- You will now be taken to your My Files page, where your newly published file will be listed. The file will take a few minutes to appear for other LBRY users; until then it will be listed as "pending." +

The file will take a few minutes to appear for other LBRY users. Until then it will be listed as "pending" under your published files.

diff --git a/ui/js/page/referral.js b/ui/js/page/referral.js deleted file mode 100644 index 1f98e49ff..000000000 --- a/ui/js/page/referral.js +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import lbry from '../lbry.js'; -import {Link} from '../component/link.js'; -import Modal from '../component/modal.js'; - -var referralCodeContentStyle = { - display: 'inline-block', - textAlign: 'left', - width: '600px', -}, referralCodeLabelStyle = { - display: 'inline-block', - cursor: 'default', - width: '130px', - textAlign: 'right', - marginRight: '6px', -}; - -var ReferralPage = React.createClass({ - getInitialState: function() { - return { - submitting: false, - modal: null, - referralCredits: null, - failureReason: null, - } - }, - handleSubmit: function(event) { - if (typeof event !== 'undefined') { - event.preventDefault(); - } - - if (!this.refs.code.value) { - this.setState({ - modal: 'missingCode', - }); - } else if (!this.refs.email.value) { - this.setState({ - modal: 'missingEmail', - }); - } - - this.setState({ - submitting: true, - }); - - lbry.getUnusedAddress((address) => { - var code = this.refs.code.value; - var email = this.refs.email.value; - - var xhr = new XMLHttpRequest; - xhr.addEventListener('load', () => { - var response = JSON.parse(xhr.responseText); - - if (response.success) { - this.setState({ - modal: 'referralInfo', - referralCredits: response.referralCredits, - }); - } else { - this.setState({ - submitting: false, - modal: 'lookupFailed', - failureReason: response.reason, - }); - } - }); - - xhr.addEventListener('error', () => { - this.setState({ - submitting: false, - modal: 'couldNotConnect', - }); - }); - - xhr.open('POST', 'https://invites.lbry.io/check', true); - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - xhr.send('code=' + encodeURIComponent(code) + '&address=' + encodeURIComponent(address) + - '&email=' + encodeURIComponent(email)); - }); - }, - closeModal: function() { - this.setState({ - modal: null, - }); - }, - handleFinished: function() { - localStorage.setItem('claimCodeDone', true); - window.location = '?home'; - }, - render: function() { - return ( -
-
-
-

Check your referral credits

-
-

Have you referred others to LBRY? Enter your referral code and email address below to check how many credits you've earned!

-

As a reminder, your referral code is the same as your LBRY invitation code.

-
-
-
-
-
-
- - -
-
-
- - {this.state.referralCredits > 0 - ? `You have earned ${response.referralCredits} credits from referrals. We will credit your account shortly. Thanks!` - : 'You have not earned any new referral credits since the last time you checked. Please check back in a week or two.'} - - - {this.state.failureReason} - - - LBRY couldn't connect to our servers to confirm your referral code. Please check your internet connection. - -
- ); - } -}); - -export default ReferralPage; diff --git a/ui/js/page/report.js b/ui/js/page/report.js index 47a4d2a7a..e76905d4b 100644 --- a/ui/js/page/report.js +++ b/ui/js/page/report.js @@ -18,9 +18,6 @@ var ReportPage = React.createClass({ this._messageArea.value = ''; } }, - componentDidMount: function() { - document.title = "Report an Issue"; - }, closeModal: function() { this.setState({ modal: null, @@ -34,7 +31,7 @@ var ReportPage = React.createClass({ }, render: function() { return ( -
+

Report an Issue

Please describe the problem you experienced and any information you think might be useful to us. Links to screenshots are great!

diff --git a/ui/js/page/reward.js b/ui/js/page/reward.js new file mode 100644 index 000000000..2fb5b3e64 --- /dev/null +++ b/ui/js/page/reward.js @@ -0,0 +1,126 @@ +import React from 'react'; +import lbryio from '../lbryio.js'; +import {Link} from '../component/link.js'; +import Notice from '../component/notice.js'; +import {CreditAmount} from '../component/common.js'; +// +// const {shell} = require('electron'); +// const querystring = require('querystring'); +// +// const GITHUB_CLIENT_ID = '6baf581d32bad60519'; +// +// const LinkGithubReward = React.createClass({ +// propTypes: { +// onClaimed: React.PropTypes.func, +// }, +// _launchLinkPage: function() { +// /* const githubAuthParams = { +// client_id: GITHUB_CLIENT_ID, +// redirect_uri: 'https://lbry.io/', +// scope: 'user:email,public_repo', +// allow_signup: false, +// } +// shell.openExternal('https://github.com/login/oauth/authorize?' + querystring.stringify(githubAuthParams)); */ +// shell.openExternal('https://lbry.io'); +// }, +// handleConfirmClicked: function() { +// this.setState({ +// confirming: true, +// }); +// +// lbry.get_new_address().then((address) => { +// lbryio.call('reward', 'new', { +// reward_type: 'new_developer', +// access_token: '**access token here**', +// wallet_address: address, +// }, 'post').then((response) => { +// console.log('response:', response); +// +// this.props.onClaimed(); // This will trigger another API call to show that we succeeded +// +// this.setState({ +// confirming: false, +// error: null, +// }); +// }, (error) => { +// console.log('failed with error:', error); +// this.setState({ +// confirming: false, +// error: error, +// }); +// }); +// }); +// }, +// getInitialState: function() { +// return { +// confirming: false, +// error: null, +// }; +// }, +// render: function() { +// return ( +//
+//

+//
+//

This will open a browser window where you can authorize GitHub to link your account to LBRY. This will record your email (no spam) and star the LBRY repo.

+//

Once you're finished, you may confirm you've linked the account to receive your reward.

+//
+// {this.state.error +// ? +// {this.state.error.message} +// +// : null} +// +// +//
+// ); +// } +// }); +// +// const RewardPage = React.createClass({ +// propTypes: { +// name: React.PropTypes.string.isRequired, +// }, +// _getRewardType: function() { +// lbryio.call('reward_type', 'get', this.props.name).then((rewardType) => { +// this.setState({ +// rewardType: rewardType, +// }); +// }); +// }, +// getInitialState: function() { +// return { +// rewardType: null, +// }; +// }, +// componentWillMount: function() { +// this._getRewardType(); +// }, +// render: function() { +// if (!this.state.rewardType) { +// return null; +// } +// +// let Reward; +// if (this.props.name == 'link_github') { +// Reward = LinkGithubReward; +// } +// +// const {title, description, value} = this.state.rewardType; +// return ( +//
+//
+//

{title}

+// +//

{this.state.rewardType.claimed +// ? This reward has been claimed. +// : description}

+// +//
+//
+// ); +// } +// }); +// +// export default RewardPage; diff --git a/ui/js/page/rewards.js b/ui/js/page/rewards.js new file mode 100644 index 000000000..3462517c9 --- /dev/null +++ b/ui/js/page/rewards.js @@ -0,0 +1,74 @@ +import React from 'react'; +import lbry from '../lbry.js'; +import lbryio from '../lbryio.js'; +import {CreditAmount, Icon} from '../component/common.js'; +import rewards from '../rewards.js'; +import Modal from '../component/modal.js'; +import {WalletNav} from './wallet.js'; +import {RewardLink} from '../component/link.js'; + +const RewardTile = React.createClass({ + propTypes: { + type: React.PropTypes.string.isRequired, + title: React.PropTypes.string.isRequired, + description: React.PropTypes.string.isRequired, + claimed: React.PropTypes.bool.isRequired, + value: React.PropTypes.number.isRequired, + onRewardClaim: React.PropTypes.func + }, + render: function() { + return ( +
+
+
+ +

{this.props.title}

+
+
+ {this.props.claimed + ? Reward claimed. + : } +
+
{this.props.description}
+
+
+ ); + } +}); + +var RewardsPage = React.createClass({ + componentWillMount: function() { + this.loadRewards() + }, + getInitialState: function() { + return { + userRewards: null, + failed: null + }; + }, + loadRewards: function() { + lbryio.call('reward', 'list', {}).then((userRewards) => { + this.setState({ + userRewards: userRewards, + }); + }, () => { + this.setState({failed: true }) + }); + }, + render: function() { + return ( +
+ +
+ {!this.state.userRewards + ? (this.state.failed ?
Failed to load rewards.
: '') + : this.state.userRewards.map(({RewardType, RewardTitle, RewardDescription, TransactionID, RewardAmount}) => { + return ; + })} +
+
+ ); + } +}); + +export default RewardsPage; diff --git a/ui/js/page/search.js b/ui/js/page/search.js new file mode 100644 index 000000000..dafeb30cf --- /dev/null +++ b/ui/js/page/search.js @@ -0,0 +1,165 @@ +import React from 'react'; +import lbry from '../lbry.js'; +import lbryio from '../lbryio.js'; +import lbryuri from '../lbryuri.js'; +import lighthouse from '../lighthouse.js'; +import {FileTile, FileTileStream} from '../component/file-tile.js'; +import {Link} from '../component/link.js'; +import {ToolTip} from '../component/tooltip.js'; +import {BusyMessage} from '../component/common.js'; + +var SearchNoResults = React.createClass({ + render: function() { + return
+ + No one has checked anything in for {this.props.query} yet. + + +
; + } +}); + +var SearchResultList = React.createClass({ + render: function() { + var rows = [], + seenNames = {}; //fix this when the search API returns claim IDs + + for (let {name, claim, claim_id, channel_name, channel_id, txid, nout} of this.props.results) { + const uri = lbryuri.build({ + channelName: channel_name, + contentName: name, + claimId: channel_id || claim_id, + }); + + rows.push( + + ); + } + return ( +
{rows}
+ ); + } +}); + +let SearchResults = React.createClass({ + propTypes: { + query: React.PropTypes.string.isRequired + }, + + _isMounted: false, + + search: function(term) { + lighthouse.search(term).then(this.searchCallback); + if (!this.state.searching) { + this.setState({ searching: true }) + } + }, + + componentWillMount: function () { + this._isMounted = true; + this.search(this.props.query); + }, + + componentWillReceiveProps: function (nextProps) { + if (nextProps.query != this.props.query) { + this.search(nextProps.query); + } + }, + + componentWillUnmount: function () { + this._isMounted = false; + }, + + getInitialState: function () { + return { + results: [], + searching: true + }; + }, + + searchCallback: function (results) { + if (this._isMounted) //could have canceled while results were pending, in which case nothing to do + { + this.setState({ + results: results, + searching: false //multiple searches can be out, we're only done if we receive one we actually care about + }); + } + }, + + render: function () { + return this.state.searching ? + : + (this.state.results && this.state.results.length ? + : + ); + } +}); + +let SearchPage = React.createClass({ + + _isMounted: false, + + propTypes: { + query: React.PropTypes.string.isRequired + }, + + isValidUri: function(query) { + try { + lbryuri.parse(query); + return true; + } catch (e) { + return false; + } + }, + + componentWillMount: function() { + this._isMounted = true; + lighthouse.search(this.props.query).then(this.searchCallback); + }, + + componentWillUnmount: function() { + this._isMounted = false; + }, + + getInitialState: function() { + return { + results: [], + searching: true + }; + }, + + searchCallback: function(results) { + if (this._isMounted) //could have canceled while results were pending, in which case nothing to do + { + this.setState({ + results: results, + searching: false //multiple searches can be out, we're only done if we receive one we actually care about + }); + } + }, + + render: function() { + return ( +
+ { this.isValidUri(this.props.query) ? +
+

+ Exact URL + +

+ +
: '' } +
+

+ Search Results for {this.props.query} + +

+ +
+
+ ); + } +}); + +export default SearchPage; diff --git a/ui/js/page/settings.js b/ui/js/page/settings.js index 508a8a84d..d523cbb4a 100644 --- a/ui/js/page/settings.js +++ b/ui/js/page/settings.js @@ -1,52 +1,63 @@ import React from 'react'; +import {FormField, FormRow} from '../component/form.js'; +import {SubHeader} from '../component/header.js'; import lbry from '../lbry.js'; -var settingsRadioOptionStyles = { - display: 'block', - marginLeft: '13px' -}, settingsCheckBoxOptionStyles = { - display: 'block', - marginLeft: '13px' -}, settingsNumberFieldStyles = { - width: '40px' -}, downloadDirectoryLabelStyles = { - fontSize: '.9em', - marginLeft: '13px' -}, downloadDirectoryFieldStyles= { - width: '300px' -}; +export let SettingsNav = React.createClass({ + render: function() { + return ; + } +}); var SettingsPage = React.createClass({ + _onSettingSaveSuccess: function() { + // This is bad. + // document.dispatchEvent(new CustomEvent('globalNotice', { + // detail: { + // message: "Settings saved", + // }, + // })) + }, + setDaemonSetting: function(name, value) { + lbry.setDaemonSetting(name, value, this._onSettingSaveSuccess) + }, + setClientSetting: function(name, value) { + lbry.setClientSetting(name, value) + this._onSettingSaveSuccess() + }, onRunOnStartChange: function (event) { - lbry.setDaemonSetting('run_on_startup', event.target.checked); + this.setDaemonSetting('run_on_startup', event.target.checked); }, onShareDataChange: function (event) { - lbry.setDaemonSetting('share_debug_info', event.target.checked); + this.setDaemonSetting('share_usage_data', event.target.checked); }, onDownloadDirChange: function(event) { - lbry.setDaemonSetting('download_directory', event.target.value); + this.setDaemonSetting('download_directory', event.target.value); }, onMaxUploadPrefChange: function(isLimited) { if (!isLimited) { - lbry.setDaemonSetting('max_upload', 0.0); + this.setDaemonSetting('max_upload', 0.0); } this.setState({ isMaxUpload: isLimited }); }, onMaxUploadFieldChange: function(event) { - lbry.setDaemonSetting('max_upload', Number(event.target.value)); + this.setDaemonSetting('max_upload', Number(event.target.value)); }, onMaxDownloadPrefChange: function(isLimited) { if (!isLimited) { - lbry.setDaemonSetting('max_download', 0.0); + this.setDaemonSetting('max_download', 0.0); } this.setState({ isMaxDownload: isLimited }); }, onMaxDownloadFieldChange: function(event) { - lbry.setDaemonSetting('max_download', Number(event.target.value)); + this.setDaemonSetting('max_download', Number(event.target.value)); }, getInitialState: function() { return { @@ -55,100 +66,144 @@ var SettingsPage = React.createClass({ showUnavailable: lbry.getClientSetting('showUnavailable'), } }, - componentDidMount: function() { - document.title = "Settings"; - }, componentWillMount: function() { - lbry.getDaemonSettings(function(settings) { + lbry.getDaemonSettings((settings) => { this.setState({ daemonSettings: settings, isMaxUpload: settings.max_upload != 0, isMaxDownload: settings.max_download != 0 }); - }.bind(this)); + }); }, onShowNsfwChange: function(event) { lbry.setClientSetting('showNsfw', event.target.checked); }, onShowUnavailableChange: function(event) { - lbry.setClientSetting('showUnavailable', event.target.checked); + }, render: function() { if (!this.state.daemonSettings) { return null; } - +/* +
+
+

Run on Startup

+
+
+ +
+
+ */ return ( -
+
+
-

Run on Startup

- -
-
-

Download Directory

-
Where would you like the files you download from LBRY to be saved?
- -
-
-

Bandwidth Limits

-
-

Max Upload

- - +
+

Download Directory

-
-

Max Download

- - +
+
-

Content

-
- -
- NSFW content may include nudity, intense sexuality, profanity, or other adult content. - By displaying NSFW content, you are affirming you are of legal age to view mature content in your country or jurisdiction. +
+

Bandwidth Limits

+
+
+
Max Upload
+ { this.onMaxUploadPrefChange(false) }} + defaultChecked={!this.state.isMaxUpload} + label="Unlimited" /> +
+ { this.onMaxUploadPrefChange(true) }} + defaultChecked={this.state.isMaxUpload} + label={ this.state.isMaxUpload ? 'Up to' : 'Choose limit...' } /> + { this.state.isMaxUpload ? + + : '' + + } + { this.state.isMaxUpload ? MB/s : '' } +
+
+
+
Max Download
+ { this.onMaxDownloadPrefChange(false) }} + defaultChecked={!this.state.isMaxDownload} /> +
+ { this.onMaxDownloadPrefChange(true) }} + defaultChecked={this.state.isMaxDownload} + label={ this.state.isMaxDownload ? 'Up to' : 'Choose limit...' } /> + { this.state.isMaxDownload ? + + : '' + + } + { this.state.isMaxDownload ? MB/s : '' }
-

Search

-
-
- Would you like search results to include items that are not currently available for download? +
+

Content

- +
+ +
+
+
-

Share Diagnostic Data

- +
+

Share Diagnostic Data

+
+
+ +
); } }); - export default SettingsPage; diff --git a/ui/js/page/show.js b/ui/js/page/show.js index 8f4d450c9..c72a8bde1 100644 --- a/ui/js/page/show.js +++ b/ui/js/page/show.js @@ -1,166 +1,284 @@ import React from 'react'; import lbry from '../lbry.js'; import lighthouse from '../lighthouse.js'; -import {CreditAmount, Thumbnail} from '../component/common.js'; +import lbryuri from '../lbryuri.js'; +import {Video} from '../page/watch.js' +import {TruncatedText, Thumbnail, FilePrice, BusyMessage} from '../component/common.js'; import {FileActions} from '../component/file-actions.js'; import {Link} from '../component/link.js'; - -var formatItemImgStyle = { - maxWidth: '100%', - maxHeight: '100%', - display: 'block', - marginLeft: 'auto', - marginRight: 'auto', - marginTop: '5px', -}; +import UriIndicator from '../component/channel-indicator.js'; var FormatItem = React.createClass({ propTypes: { - claimInfo: React.PropTypes.object, - cost: React.PropTypes.number, - name: React.PropTypes.string, + metadata: React.PropTypes.object, + contentType: React.PropTypes.string, + uri: React.PropTypes.string, outpoint: React.PropTypes.string, - costIncludesData: React.PropTypes.bool, }, render: function() { - var claimInfo = this.props.claimInfo; - var thumbnail = claimInfo.thumbnail; - var title = claimInfo.title; - var description = claimInfo.description; - var author = claimInfo.author; - var language = claimInfo.language; - var license = claimInfo.license; - var fileContentType = (claimInfo.content_type || claimInfo['content-type']); - var mediaType = lbry.getMediaType(fileContentType); - var costIncludesData = this.props.costIncludesData; - var cost = this.props.cost || 0.0; + const {author, language, license} = this.props.metadata; - return ( -
-
- -
-
-

{description}

-
- - - - - - - - - - - - - - - - - - -
Content-Type{fileContentType}
Cost
Author{author}
Language{language}
License{license}
-
- -
- -
-
-
- ); - } -}); - -var FormatsSection = React.createClass({ - propTypes: { - claimInfo: React.PropTypes.object, - cost: React.PropTypes.number, - name: React.PropTypes.string, - costIncludesData: React.PropTypes.bool, - }, - render: function() { - var name = this.props.name; - var format = this.props.claimInfo; - var title = format.title; - - if(format == null) - { - return ( -
-

Sorry, no results found for "{name}".

-
); - } - - return ( -
-
lbry://{name}
-

{title}

- {/* In future, anticipate multiple formats, just a guess at what it could look like - // var formats = this.props.claimInfo.formats - // return ({formats.map(function(format,i){ */} - - {/* })}); */} -
); - } -}); - -var DetailPage = React.createClass({ - propTypes: { - name: React.PropTypes.string, - }, - getInitialState: function() { - return { - metadata: null, - cost: null, - costIncludesData: null, - nameLookupComplete: null, - }; - }, - componentWillMount: function() { - document.title = 'lbry://' + this.props.name; - - lbry.claim_show({name: this.props.name}, ({name, txid, nout, value}) => { - this.setState({ - outpoint: txid + ':' + nout, - metadata: value, - nameLookupComplete: true, - }); - }); - - lbry.getCostInfoForName(this.props.name, ({cost, includesData}) => { - this.setState({ - cost: cost, - costIncludesData: includesData, - }); - }); - }, - render: function() { - if (this.state.metadata == null) { + if (!this.props.contentType && [author, language, license].filter((val) => {return !!val; }).length === 0) { return null; } - const name = this.props.name; - const costIncludesData = this.state.costIncludesData; - const metadata = this.state.metadata; - const cost = this.state.cost; - const outpoint = this.state.outpoint; - return ( -
-
- {this.state.nameLookupComplete ? ( - - ) : ( -
-

No content

- There is no content available at the name lbry://{this.props.name}. If you reached this page from a link within the LBRY interface, please . Thanks! -
- )} -
-
); + + + + + + + + + + + + + + + +
Content-Type{this.props.contentType}
Author{author}
Language{language}
License{license}
+ ); } }); -export default DetailPage; +let ChannelPage = React.createClass({ + render: function() { + return
+
+
+

{this.props.title}

+
+
+

+ This channel page is a stub. +

+
+
+
+ } +}); + +let FilePage = React.createClass({ + _isMounted: false, + + propTypes: { + uri: React.PropTypes.string, + }, + + getInitialState: function() { + return { + cost: null, + costIncludesData: null, + isDownloaded: null, + }; + }, + + componentWillUnmount: function() { + this._isMounted = false; + }, + + componentWillReceiveProps: function(nextProps) { + if (nextProps.outpoint != this.props.outpoint || nextProps.uri != this.props.uri) { + this.loadCostAndFileState(nextProps.uri, nextProps.outpoint); + } + }, + + componentWillMount: function() { + this._isMounted = true; + this.loadCostAndFileState(this.props.uri, this.props.outpoint); + }, + + loadCostAndFileState: function(uri, outpoint) { + lbry.file_list({outpoint: outpoint}).then((fileInfo) => { + if (this._isMounted) { + this.setState({ + isDownloaded: fileInfo.length > 0, + }); + } + }); + + lbry.getCostInfo(uri).then(({cost, includesData}) => { + if (this._isMounted) { + this.setState({ + cost: cost, + costIncludesData: includesData, + }); + } + }); + }, + + render: function() { + const metadata = this.props.metadata, + title = metadata ? this.props.metadata.title : this.props.uri, + uriIndicator = ; + + return ( +
+
+ { this.props.contentType && this.props.contentType.startsWith('video/') ? +
+
+
+
+ {this.state.isDownloaded === false + ? + : null} +

{title}

+
+ { this.props.channelUri ? + {uriIndicator} : + uriIndicator} +
+
+ +
+
+
+ {metadata.description} +
+
+ { metadata ? +
+ +
: '' } +
+ +
+
+
+ ); + } +}); + +let ShowPage = React.createClass({ + _uri: null, + _isMounted: false, + + propTypes: { + uri: React.PropTypes.string, + }, + + getInitialState: function() { + return { + outpoint: null, + metadata: null, + contentType: null, + hasSignature: false, + claimType: null, + signatureIsValid: false, + cost: null, + costIncludesData: null, + uriLookupComplete: null, + isFailed: false, + }; + }, + + componentWillUnmount: function() { + this._isMounted = false; + }, + + componentWillReceiveProps: function(nextProps) { + if (nextProps.uri != this.props.uri) { + this.setState(this.getInitialState()); + this.loadUri(nextProps.uri); + } + }, + + componentWillMount: function() { + this._isMounted = true; + this.loadUri(this.props.uri); + }, + + loadUri: function(uri) { + this._uri = lbryuri.normalize(uri); + + lbry.resolve({uri: this._uri}).then((resolveData) => { + const isChannel = resolveData && resolveData.claims_in_channel; + if (!this._isMounted) { + return; + } + if (resolveData) { + let newState = { uriLookupComplete: true } + if (!isChannel) { + let {claim: {txid: txid, nout: nout, has_signature: has_signature, signature_is_valid: signature_is_valid, value: {stream: {metadata: metadata, source: {contentType: contentType}}}}} = resolveData; + + Object.assign(newState, { + claimType: "file", + metadata: metadata, + outpoint: txid + ':' + nout, + hasSignature: has_signature, + signatureIsValid: signature_is_valid, + contentType: contentType + }); + + + lbry.setTitle(metadata.title ? metadata.title : this._uri) + + } else { + let {certificate: {txid: txid, nout: nout, has_signature: has_signature}} = resolveData; + Object.assign(newState, { + claimType: "channel", + outpoint: txid + ':' + nout, + txid: txid, + metadata: { + title:resolveData.certificate.name + } + }); + } + + this.setState(newState); + + } else { + this.setState(Object.assign({}, this.getInitialState(), { + uriLookupComplete: true, + isFailed: true + })); + } + }); + }, + + render: function() { + const metadata = this.state.metadata, + title = metadata ? this.state.metadata.title : this._uri; + + let innerContent = ""; + + if (!this.state.uriLookupComplete || this.state.isFailed) { + innerContent =
+
+

{title}

+
+
+ { this.state.uriLookupComplete ? +

This location is not yet in use. { ' ' }.

: + + } +
+
; + } else if (this.state.claimType == "channel") { + innerContent = + } else { + let channelUriObj = lbryuri.parse(this._uri) + delete channelUriObj.path; + delete channelUriObj.contentName; + const channelUri = this.state.signatureIsValid && this.state.hasSignature && channelUriObj.isChannel ? lbryuri.build(channelUriObj, false) : null; + innerContent = ; + } + + return
{innerContent}
; + } +}); + +export default ShowPage; diff --git a/ui/js/page/start.js b/ui/js/page/start.js index 9f918db27..e1cf6e7dd 100644 --- a/ui/js/page/start.js +++ b/ui/js/page/start.js @@ -5,12 +5,9 @@ var StartPage = React.createClass({ componentWillMount: function() { lbry.stop(); }, - componentDidMount: function() { - document.title = "LBRY is Closed"; - }, render: function() { return ( -
+

LBRY is Closed

diff --git a/ui/js/page/wallet.js b/ui/js/page/wallet.js index 2ace64c27..f50f4e1be 100644 --- a/ui/js/page/wallet.js +++ b/ui/js/page/wallet.js @@ -2,12 +2,10 @@ import React from 'react'; import lbry from '../lbry.js'; import {Link} from '../component/link.js'; import Modal from '../component/modal.js'; +import {SubHeader} from '../component/header.js'; +import {FormField, FormRow} from '../component/form.js'; import {Address, BusyMessage, CreditAmount} from '../component/common.js'; - -var addressRefreshButtonStyle = { - fontSize: '11pt', -}; var AddressSection = React.createClass({ _refreshAddress: function(event) { if (typeof event !== 'undefined') { @@ -60,12 +58,20 @@ var AddressSection = React.createClass({ render: function() { return (
-

Wallet Address

-
- -
-

Other LBRY users may send credits to you by entering this address on the "Send" page.

- You can generate a new address at any time, and any previous addresses will continue to work. Using multiple addresses can be helpful for keeping track of incoming payments from multiple sources. +
+

Wallet Address

+
+
+
+
+
+ +
+
+
+

Other LBRY users may send credits to you by entering this address on the "Send" page.

+

You can generate a new address at any time, and any previous addresses will continue to work. Using multiple addresses can be helpful for keeping track of incoming payments from multiple sources.

+
); @@ -143,27 +149,26 @@ var SendToAddressSection = React.createClass({ return (
-

Send Credits

-
- - +
+

Send Credits

-
- - +
+
-
+
+ +
+
0.0) || this.state.address == ""} />
- { - this.state.results ? -
-

Results

- {this.state.results} -
- : '' - } + { + this.state.results ? +
+

Results

+ {this.state.results} +
: '' + } @@ -231,30 +236,44 @@ var TransactionList = React.createClass({ } return (
-

Transaction History

- { this.state.transactionItems === null ? : '' } - { this.state.transactionItems && rows.length === 0 ?
You have no transactions.
: '' } - { this.state.transactionItems && rows.length > 0 ? - - - - - - - - - - - {rows} - -
AmountDateTimeTransaction
+
+

Transaction History

+
+
+ { this.state.transactionItems === null ? : '' } + { this.state.transactionItems && rows.length === 0 ?
You have no transactions.
: '' } + { this.state.transactionItems && rows.length > 0 ? + + + + + + + + + + + {rows} + +
AmountDateTimeTransaction
: '' - } + } +
); } }); +export let WalletNav = React.createClass({ + render: function() { + return ; + } +}); var WalletPage = React.createClass({ _balanceSubscribeId: null, @@ -262,9 +281,6 @@ var WalletPage = React.createClass({ propTypes: { viewingPage: React.PropTypes.string, }, - componentDidMount: function() { - document.title = "My Wallet"; - }, /* Below should be refactored so that balance is shared all of wallet page. Or even broader? What is the proper React pattern for sharing a global state like balance? @@ -288,11 +304,16 @@ var WalletPage = React.createClass({ }, render: function() { return ( -
+
+
-

Balance

- { this.state.balance === null ? : ''} - { this.state.balance !== null ? : '' } +
+

Balance

+
+
+ { this.state.balance === null ? : ''} + { this.state.balance !== null ? : '' } +
{ this.props.viewingPage === 'wallet' ? : '' } { this.props.viewingPage === 'send' ? : '' } diff --git a/ui/js/page/watch.js b/ui/js/page/watch.js index 9d7bdb75b..2f01ae93b 100644 --- a/ui/js/page/watch.js +++ b/ui/js/page/watch.js @@ -1,39 +1,136 @@ import React from 'react'; -import {Icon} from '../component/common.js'; +import {Icon, Thumbnail, FilePrice} from '../component/common.js'; import {Link} from '../component/link.js'; import lbry from '../lbry.js'; +import Modal from '../component/modal.js'; +import lbryio from '../lbryio.js'; +import rewards from '../rewards.js'; import LoadScreen from '../component/load_screen.js' const fs = require('fs'); const VideoStream = require('videostream'); +export let WatchLink = React.createClass({ + propTypes: { + uri: React.PropTypes.string, + metadata: React.PropTypes.object, + downloadStarted: React.PropTypes.bool, + onGet: React.PropTypes.func, + }, + getInitialState: function() { + affirmedPurchase: false + }, + play: function() { + lbry.get({uri: this.props.uri}).then((streamInfo) => { + if (streamInfo === null || typeof streamInfo !== 'object') { + this.setState({ + modal: 'timedOut', + attemptingDownload: false, + }); + } -var WatchPage = React.createClass({ + lbryio.call('file', 'view', { + uri: this.props.uri, + outpoint: streamInfo.outpoint, + claimId: streamInfo.claim_id + }).catch(() => {}) + }); + if (this.props.onGet) { + this.props.onGet() + } + }, + onWatchClick: function() { + this.setState({ + loading: true + }); + lbry.getCostInfo(this.props.uri).then(({cost}) => { + lbry.getBalance((balance) => { + if (cost > balance) { + this.setState({ + modal: 'notEnoughCredits', + attemptingDownload: false, + }); + } else if (cost <= 0.01) { + this.play() + } else { + lbry.file_list({outpoint: this.props.outpoint}).then((fileInfo) => { + if (fileInfo) { // Already downloaded + this.play(); + } else { + this.setState({ + modal: 'affirmPurchase' + }); + } + }); + } + }); + }); + }, + getInitialState: function() { + return { + modal: null, + loading: false, + }; + }, + closeModal: function() { + this.setState({ + loading: false, + modal: null, + }); + }, + render: function() { + return (
+ + + You don't have enough LBRY credits to pay for this stream. + + + Are you sure you'd like to buy {this.props.metadata.title} for credits? + +
); + } +}); + + +export let Video = React.createClass({ _isMounted: false, _controlsHideDelay: 3000, // Note: this needs to be shorter than the built-in delay in Electron, or Electron will hide the controls before us _controlsHideTimeout: null, - _outpoint: null, propTypes: { - name: React.PropTypes.string, + uri: React.PropTypes.string.isRequired, + metadata: React.PropTypes.object, + outpoint: React.PropTypes.string, }, getInitialState: function() { return { downloadStarted: false, readyToPlay: false, - loadStatusMessage: "Requesting stream", + isPlaying: false, + isPurchased: false, + loadStatusMessage: "Requesting stream... it may sit here for like 15-20 seconds in a really awkward way... we're working on it", mimeType: null, controlsShown: false, }; }, - componentDidMount: function() { - lbry.get({name: this.props.name}).then((fileInfo) => { - this._outpoint = fileInfo.outpoint; + onGet: function() { + lbry.get({uri: this.props.uri}).then((fileInfo) => { this.updateLoadStatus(); }); + this.setState({ + isPlaying: true + }) }, - handleBackClicked: function() { - history.back(); + componentDidMount: function() { + if (this.props.autoplay) { + this.start() + } }, handleMouseMove: function() { if (this._controlsTimeout) { @@ -68,7 +165,7 @@ var WatchPage = React.createClass({ }, updateLoadStatus: function() { lbry.file_list({ - outpoint: this._outpoint, + outpoint: this.props.outpoint, full_status: true, }).then(([status]) => { if (!status || status.written_bytes == 0) { @@ -93,6 +190,9 @@ var WatchPage = React.createClass({ return fs.createReadStream(status.download_path, opts) } }; + + rewards.claimNextPurchaseReward() + var elem = this.refs.video; var videostream = VideoStream(mediaFile, elem); elem.play(); @@ -101,26 +201,15 @@ var WatchPage = React.createClass({ }, render: function() { return ( - !this.state.readyToPlay - ? - :
- - {this.state.controlsShown - ?
-
- -
- -
- Back to LBRY -
-
-
-
- : null} -
+
{ + this.state.isPlaying ? + !this.state.readyToPlay ? + this is the world's worst loading screen and we shipped our software with it anyway...

{this.state.loadStatusMessage}
: + : +
+ +
+ }
); } -}); - -export default WatchPage; +}) diff --git a/ui/js/rewards.js b/ui/js/rewards.js new file mode 100644 index 000000000..399965db2 --- /dev/null +++ b/ui/js/rewards.js @@ -0,0 +1,124 @@ +import lbry from './lbry.js'; +import lbryio from './lbryio.js'; + +function rewardMessage(type, amount) { + return { + new_developer: `You earned ${amount} for registering as a new developer.`, + new_user: `You earned ${amount} LBC new user reward.`, + confirm_email: `You earned ${amount} LBC for verifying your email address.`, + new_channel: `You earned ${amount} LBC for creating a publisher identity.`, + first_stream: `You earned ${amount} LBC for streaming your first video.`, + many_downloads: `You earned ${amount} LBC for downloading some of the things.`, + first_publish: `You earned ${amount} LBC for making your first publication.`, + }[type]; +} + +const rewards = {}; + +rewards.TYPE_NEW_DEVELOPER = "new_developer", + rewards.TYPE_NEW_USER = "new_user", + rewards.TYPE_CONFIRM_EMAIL = "confirm_email", + rewards.TYPE_FIRST_CHANNEL = "new_channel", + rewards.TYPE_FIRST_STREAM = "first_stream", + rewards.TYPE_MANY_DOWNLOADS = "many_downloads", + rewards.TYPE_FIRST_PUBLISH = "first_publish"; + +rewards.claimReward = function (type) { + + function requestReward(resolve, reject, params) { + if (!lbryio.enabled) { + reject(new Error("Rewards are not enabled.")) + return; + } + lbryio.call('reward', 'new', params, 'post').then(({RewardAmount}) => { + const + message = rewardMessage(type, RewardAmount), + result = { + type: type, + amount: RewardAmount, + message: message + }; + + // Display global notice + document.dispatchEvent(new CustomEvent('globalNotice', { + detail: { + message: message, + linkText: "Show All", + linkTarget: "?rewards", + isError: false, + }, + })); + + // Add more events here to display other places + + resolve(result); + }, reject); + } + + return new Promise((resolve, reject) => { + lbry.wallet_new_address().then((address) => { + const params = { + reward_type: type, + wallet_address: address, + }; + + switch (type) { + case rewards.TYPE_FIRST_CHANNEL: + lbry.claim_list_mine().then(function(claims) { + let claim = claims.find(function(claim) { + return claim.name.length && claim.name[0] == '@' && claim.txid.length + }) + if (claim) { + params.transaction_id = claim.txid; + requestReward(resolve, reject, params) + } else { + reject(new Error("Please create a channel identity first.")) + } + }).catch(reject) + break; + + case rewards.TYPE_FIRST_PUBLISH: + lbry.claim_list_mine().then((claims) => { + let claim = claims.find(function(claim) { + return claim.name.length && claim.name[0] != '@' && claim.txid.length + }) + if (claim) { + params.transaction_id = claim.txid + requestReward(resolve, reject, params) + } else { + reject(claims.length ? + new Error("Please publish something and wait for confirmation by the network to claim this reward.") : + new Error("Please publish something to claim this reward.")) + } + }).catch(reject) + break; + + case rewards.TYPE_FIRST_STREAM: + case rewards.TYPE_NEW_USER: + default: + requestReward(resolve, reject, params); + } + }); + }); +} + +rewards.claimNextPurchaseReward = function() { + let types = {} + types[rewards.TYPE_FIRST_STREAM] = false + types[rewards.TYPE_MANY_DOWNLOADS] = false + lbryio.call('reward', 'list', {}).then((userRewards) => { + userRewards.forEach((reward) => { + if (types[reward.RewardType] === false && reward.TransactionID) { + types[reward.RewardType] = true + } + }) + let unclaimedType = Object.keys(types).find((type) => { + return types[type] === false; + }) + if (unclaimedType) { + rewards.claimReward(unclaimedType); + } + }, () => { }); +} + +export default rewards; \ No newline at end of file diff --git a/ui/js/uri.js b/ui/js/uri.js deleted file mode 100644 index 98883c6be..000000000 --- a/ui/js/uri.js +++ /dev/null @@ -1,118 +0,0 @@ -const CHANNEL_NAME_MIN_LEN = 4; -const CLAIM_ID_MAX_LEN = 40; - -const uri = {}; - -/** - * Parses a LBRY name into its component parts. Throws errors with user-friendly - * messages for invalid names. - * - * Returns a dictionary with keys: - * - name (string) - * - properName (string; strips off @ for channels) - * - isChannel (boolean) - * - claimSequence (int, if present) - * - bidPosition (int, if present) - * - claimId (string, if present) - * - path (string, if persent) - */ -uri.parseLbryUri = function(lbryUri, requireProto=false) { - // Break into components. Empty sub-matches are converted to null - const componentsRegex = new RegExp( - '^((?:lbry:\/\/)?)' + // protocol - '([^:$#/]*)' + // name (stops at the first separator or end) - '([:$#]?)([^/]*)' + // modifier separator, modifier (stops at the first path separator or end) - '(/?)(.*)' // path separator, path - ); - const [proto, name, modSep, modVal, pathSep, path] = componentsRegex.exec(lbryUri).slice(1).map(match => match || null); - - // Validate protocol - if (requireProto && !proto) { - throw new Error('LBRY URIs must include a protocol prefix (lbry://).'); - } - - // Validate and process name - if (!name) { - throw new Error('URI does not include name.'); - } - - const isChannel = name[0] == '@'; - const properName = isChannel ? name.substr(1) : name; - - if (isChannel) { - if (!properName) { - throw new Error('No channel name after @.'); - } - - if (properName.length < CHANNEL_NAME_MIN_LEN) { - throw new Error(`Channel names must be at least ${CHANNEL_NAME_MIN_LEN} characters.`); - } - } - - const nameBadChars = properName.match(/[^A-Za-z0-9-]/g); - if (nameBadChars) { - throw new Error(`Invalid character${nameBadChars.length == 1 ? '' : 's'} in name: ${nameBadChars.join(', ')}.`); - } - - // Validate and process modifier (claim ID, bid position or claim sequence) - let claimId, claimSequence, bidPosition; - if (modSep) { - if (!modVal) { - throw new Error(`No modifier provided after separator ${modSep}.`); - } - - if (modSep == '#') { - claimId = modVal; - } else if (modSep == ':') { - claimSequence = modVal; - } else if (modSep == '$') { - bidPosition = modVal; - } - } - - if (claimId && (claimId.length > CLAIM_ID_MAX_LEN || !claimId.match(/^[0-9a-f]+$/))) { - throw new Error(`Invalid claim ID ${claimId}.`); - } - - if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]+$/)) { - throw new Error('Bid position must be a number.'); - } - - if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]+$/)) { - throw new Error('Claim sequence must be a number.'); - } - - // Validate path - if (path) { - if (!isChannel) { - throw new Error('Only channel URIs may have a path.'); - } - - const pathBadChars = path.match(/[^A-Za-z0-9-]/g); - if (pathBadChars) { - throw new Error(`Invalid character${count == 1 ? '' : 's'} in path: ${nameBadChars.join(', ')}`); - } - } else if (pathSep) { - throw new Error('No path provided after /'); - } - - return { - name, properName, isChannel, - ... claimSequence ? {claimSequence: parseInt(claimSequence)} : {}, - ... bidPosition ? {bidPosition: parseInt(bidPosition)} : {}, - ... claimId ? {claimId} : {}, - ... path ? {path} : {}, - }; -} - -uri.buildLbryUri = function(uriObj, includeProto=true) { - const {name, claimId, claimSequence, bidPosition, path} = uriObj; - - return (includeProto ? 'lbry://' : '') + name + - (claimId ? `#${claimId}` : '') + - (claimSequence ? `:${claimSequence}` : '') + - (bidPosition ? `\$${bidPosition}` : '') + - (path ? `/${path}` : ''); -} - -export default uri; diff --git a/ui/js/utils.js b/ui/js/utils.js index 5b5cf246a..61bf53188 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -2,9 +2,9 @@ * Thin wrapper around localStorage.getItem(). Parses JSON and returns undefined if the value * is not set yet. */ -export function getLocal(key) { +export function getLocal(key, fallback=undefined) { const itemRaw = localStorage.getItem(key); - return itemRaw === null ? undefined : JSON.parse(itemRaw); + return itemRaw === null ? fallback : JSON.parse(itemRaw); } /** @@ -13,3 +13,19 @@ export function getLocal(key) { export function setLocal(key, value) { localStorage.setItem(key, JSON.stringify(value)); } + +/** + * Thin wrapper around localStorage.getItem(). Parses JSON and returns undefined if the value + * is not set yet. + */ +export function getSession(key, fallback=undefined) { + const itemRaw = sessionStorage.getItem(key); + return itemRaw === null ? fallback : JSON.parse(itemRaw); +} + +/** + * Thin wrapper around localStorage.setItem(). Converts value to JSON. + */ +export function setSession(key, value) { + sessionStorage.setItem(key, JSON.stringify(value)); +} \ No newline at end of file diff --git a/ui/package.json b/ui/package.json index 21acf2fb6..20509ca36 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "lbry-web-ui", - "version": "0.9.2rc15", + "version": "0.10.0", "description": "LBRY UI", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" @@ -21,7 +21,6 @@ "babel-cli": "^6.11.4", "babel-preset-es2015": "^6.13.2", "babel-preset-react": "^6.11.1", - "clamp-js-main": "^0.11.1", "mediaelement": "^2.23.4", "node-sass": "^3.8.0", "rc-progress": "^2.0.6", diff --git a/ui/scss/_canvas.scss b/ui/scss/_canvas.scss deleted file mode 100644 index a5082d0d9..000000000 --- a/ui/scss/_canvas.scss +++ /dev/null @@ -1,246 +0,0 @@ -@import "global"; - -html -{ - height: 100%; - font-size: $font-size; -} -body -{ - font-family: 'Source Sans Pro', sans-serif; - line-height: $font-line-height; -} - -$drawer-width: 240px; - -#drawer -{ - width: $drawer-width; - position: fixed; - min-height: 100vh; - left: 0; - top: 0; - background: $color-bg; - z-index: 3; - .drawer-item - { - display: block; - padding: $spacing-vertical / 2; - font-size: 1.2em; - height: $spacing-vertical * 1.5; - .icon - { - margin-right: 6px; - } - .link-label - { - line-height: $spacing-vertical * 1.5; - } - .badge - { - float: right; - background: $color-money; - display: inline-block; - padding: 2px; - color: white; - margin-top: $spacing-vertical * 0.25 - 2; - border-radius: 2px; - } - } - .drawer-item-selected - { - background: $color-canvas; - color: $color-primary; - } -} -#drawer-handle -{ - padding: $spacing-vertical / 2; - max-height: $height-header - $spacing-vertical; - text-align: center; -} - -#window.drawer-closed -{ - #drawer { display: none } -} -#window.drawer-open -{ - #main-content { margin-left: $drawer-width; } - .open-drawer-link { display: none } - #header { padding-left: $drawer-width + $spacing-vertical / 2; } -} - -#header -{ - background: $color-primary; - color: white; - &.header-no-subnav { - height: $height-header; - } - &.header-with-subnav { - height: $height-header * 2; - } - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 2; - box-sizing: border-box; - h1 { font-size: 1.8em; line-height: $height-header - $spacing-vertical; display: inline-block; float: left; } - &.header-scrolled - { - box-shadow: $default-box-shadow; - } -} -.header-top-bar -{ - padding: $spacing-vertical / 2; -} -.header-search -{ - margin-left: 60px; - text-align: center; - input[type="search"] { - background: rgba(255, 255, 255, 0.3); - color: white; - width: 400px; - @include placeholder-color(#e8e8e8); - } -} - -nav.sub-header -{ - background: $color-primary; - text-transform: uppercase; - padding: $spacing-vertical / 2; - > a - { - $sub-header-selected-underline-height: 2px; - display: inline-block; - margin: 0 15px; - padding: 0 5px; - line-height: $height-header - $spacing-vertical - $sub-header-selected-underline-height; - color: #e8e8e8; - &:first-child - { - margin-left: 0; - } - &:last-child - { - margin-right: 0; - } - &.sub-header-selected - { - border-bottom: $sub-header-selected-underline-height solid #fff; - color: #fff; - } - &:hover - { - color: #fff; - } - } -} - -#main-content -{ - background: $color-canvas; - &.no-sub-nav - { - min-height: calc(100vh - 60px); //should be -$height-header, but I'm dumb I guess? It wouldn't work - main { margin-top: $height-header; } - } - &.with-sub-nav - { - min-height: calc(100vh - 120px); //should be -$height-header, but I'm dumb I guess? It wouldn't work - main { margin-top: $height-header * 2; } - } - main - { - padding: $spacing-vertical; - } - h2 - { - margin-bottom: $spacing-vertical; - } - h3, h4 - { - margin-bottom: $spacing-vertical / 2; - margin-top: $spacing-vertical; - &:first-child - { - margin-top: 0; - } - } - .meta - { - + h2, + h3, + h4 - { - margin-top: 0; - } - } -} - -$header-icon-size: 1.5em; - -.open-drawer-link, .close-drawer-link -{ - display: inline-block; - font-size: $header-icon-size; - padding: 2px 6px 0 6px; - float: left; -} -.close-lbry-link -{ - font-size: $header-icon-size; - float: right; - padding: 0 6px 0 18px; -} - -.card { - margin-left: auto; - margin-right: auto; - max-width: 800px; - padding: $spacing-vertical; - background: $color-bg; - box-shadow: $default-box-shadow; - border-radius: 2px; -} -.card-obscured -{ - position: relative; -} -.card-obscured .card-content { - -webkit-filter: blur($blur-intensity); - -moz-filter: blur($blur-intensity); - -o-filter: blur($blur-intensity); - -ms-filter: blur($blur-intensity); - filter: blur($blur-intensity); -} -.card-overlay { - position: absolute; - left: 0px; - right: 0px; - top: 0px; - bottom: 0px; - padding: 20px; - background-color: rgba(128, 128, 128, 0.8); - color: #fff; - display: flex; - align-items: center; - font-weight: 600; -} - -.card-series-submit -{ - margin-left: auto; - margin-right: auto; - max-width: 800px; - padding: $spacing-vertical / 2; -} - -.full-screen -{ - width: 100%; - height: 100%; -} \ No newline at end of file diff --git a/ui/scss/_global.scss b/ui/scss/_global.scss index 201409835..180a9d41c 100644 --- a/ui/scss/_global.scss +++ b/ui/scss/_global.scss @@ -6,17 +6,20 @@ $padding-button: 12px; $padding-text-link: 4px; $color-primary: #155B4A; +$color-primary-light: saturate(lighten($color-primary, 50%), 20%); $color-light-alt: hsl(hue($color-primary), 15, 85); $color-text-dark: #000; +$color-black-transparent: rgba(32,32,32,0.9); $color-help: rgba(0,0,0,.6); -$color-notice: #921010; -$color-warning: #ffffff; +$color-notice: #8a6d3b; +$color-error: #a94442; $color-load-screen-text: #c3c3c3; $color-canvas: #f5f5f5; $color-bg: #ffffff; $color-bg-alt: #D9D9D9; $color-money: #216C2A; $color-meta-light: #505050; +$color-form-border: rgba(160,160,160,.5); $font-size: 16px; $font-line-height: 1.3333; @@ -25,10 +28,16 @@ $mobile-width-threshold: 801px; $max-content-width: 1000px; $max-text-width: 660px; +$width-page-constrained: 800px; + $height-header: $spacing-vertical * 2.5; $height-button: $spacing-vertical * 1.5; +$height-video-embedded: $width-page-constrained * 9 / 16; -$default-box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); +$box-shadow-layer: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); +$box-shadow-focus: 2px 4px 4px 0 rgba(0,0,0,.14),2px 5px 3px -2px rgba(0,0,0,.2),2px 3px 7px 0 rgba(0,0,0,.12); + +$transition-standard: .225s ease; $blur-intensity: 8px; @@ -151,4 +160,35 @@ $blur-intensity: 8px; width:1px; height:1px; overflow:hidden; +} + +@mixin text-link($color: $color-primary, $hover-opacity: 0.70) { + .icon + { + &:first-child { + padding-right: 5px; + } + &:last-child:not(:only-child) { + padding-left: 5px; + } + } + + &:not(.no-underline) { + text-decoration: underline; + .icon { + text-decoration: none; + } + } + &:hover + { + opacity: $hover-opacity; + transition: opacity $transition-standard; + text-decoration: underline; + .icon { + text-decoration: none; + } + } + + color: $color; + cursor: pointer; } \ No newline at end of file diff --git a/ui/scss/_grid.scss b/ui/scss/_grid.scss deleted file mode 100644 index cdaa42133..000000000 --- a/ui/scss/_grid.scss +++ /dev/null @@ -1,87 +0,0 @@ -@import "global"; - -$gutter_fluid: 4; - -[class*="span"] { - min-height: 1px; - max-width: 100%; -} - -.span12 { width: 100%; } -.span11 { width: 91.666%; } -.span10 { width: 83.333%; } -.span9 { width: 75%; } -.span8 { width: 66.666%; } -.span7 { width: 58.333%; } -.span6 { width: 50%; } -.span5 { width: 41.666%; } -.span4 { width: 33.333%; } -.span3 { width: 25%; } -.span2 { width: 16.666%; } -.span1 { width: 8.333%; } - -.row-fluid { - width: 100%; - > [class*="span"] { - float: left; - width: 100%; - margin-left: 1% * $gutter_fluid; - &:first-child - { - margin-left: 0; - } - } - - $column_width: (100% - $gutter_fluid * 11) / 12; - - > .span12 { width: $column_width * 12 + $gutter_fluid * 11; } - > .span11 { width: $column_width * 11 + $gutter_fluid * 10; } - > .span10 { width: $column_width * 10 + $gutter_fluid * 9; } - > .span9 { width: $column_width * 9 + $gutter_fluid * 8; } - > .span8 { width: $column_width * 8 + $gutter_fluid * 7; } - > .span7 { width: $column_width * 7 + $gutter_fluid * 6; } - > .span6 { width: $column_width * 6 + $gutter_fluid * 5; } - > .span5 { width: $column_width * 5 + $gutter_fluid * 4; } - > .span4 { width: $column_width * 4 + $gutter_fluid * 3; } - > .span3 { width: $column_width * 3 + $gutter_fluid * 2; } - > .span2 { width: $column_width * 2 + $gutter_fluid * 1; } - > .span1 { width: $column_width; } -} - -.tile-fluid { - width: 100%; - > [class*="span"] { - float: left; - } -} - -.column-fluid { - @include display-flex(); - flex-wrap: wrap; - > [class*="span"] { - @include display-flex(); - @include flex(1 0 auto); - overflow: hidden; - justify-content: center; - } -} - -.row-fluid, .tile-fluid { - @include clearfix(); -} - -@media (max-width: $mobile-width-threshold) { - .row-fluid, .tile-fluid, .column-fluid { - width: 100%; - } - .pull-left, .pull-right - { - float: none; - } - [class*="span"] { - float: none !important; - width: 100% !important; - margin-left: 0 !important; - display: block !important; - } -} \ No newline at end of file diff --git a/ui/scss/_gui.scss b/ui/scss/_gui.scss index 1fb53790c..3d85f4b22 100644 --- a/ui/scss/_gui.scss +++ b/ui/scss/_gui.scss @@ -1,35 +1,51 @@ @import "global"; -@mixin text-link($color: $color-primary, $hover-opacity: 0.70) { +html +{ + height: 100%; + font-size: $font-size; +} +body +{ + font-family: 'Source Sans Pro', sans-serif; + line-height: $font-line-height; +} - .icon +#window +{ + min-height: 100vh; + background: $color-canvas; +} + +.badge +{ + background: $color-money; + display: inline-block; + padding: 2px; + color: white; + border-radius: 2px; +} +.credit-amount--indicator +{ + font-weight: bold; + color: $color-money; +} + +#main-content +{ + padding: $spacing-vertical; + margin-top: $height-header; + display: flex; + flex-direction: column; + main { + margin-left: auto; + margin-right: auto; + max-width: 100%; + } + main.main--single-column { - &:first-child { - padding-right: 5px; - } - &:last-child:not(:only-child) { - padding-left: 5px; - } + width: $width-page-constrained; } - - &:not(.no-underline) { - text-decoration: underline; - .icon { - text-decoration: none; - } - } - &:hover - { - opacity: $hover-opacity; - transition: opacity .225s ease; - text-decoration: underline; - .icon { - text-decoration: none; - } - } - - color: $color; - cursor: pointer; } .icon-fixed-width { @@ -38,26 +54,6 @@ text-align: center; } -section -{ - margin-bottom: $spacing-vertical; - &:last-child - { - margin-bottom: 0; - } - &:only-child { - /* If it's an only child, assume it's part of a React layout that will handle the last child condition on its own */ - margin-bottom: $spacing-vertical; - } -} - -main h1 { - font-size: 2.0em; - margin-bottom: $spacing-vertical; - margin-top: $spacing-vertical*2; - font-family: 'Raleway', sans-serif; -} - h2 { font-size: 1.75em; } @@ -76,11 +72,6 @@ sup, sub { sup { top: -0.4em; } sub { top: 0.4em; } -label { - cursor: default; - display: block; -} - code { font: 0.8em Consolas, 'Lucida Console', 'Source Sans', monospace; background-color: #eee; @@ -104,25 +95,11 @@ p opacity: 0.7; } -input[type="text"], input[type="search"], textarea -{ - @include placeholder { - color: lighten($color-text-dark, 60%); - } - border: 2px solid rgba(160,160,160,.5); - padding-left: 5px; - padding-right: 5px; - box-sizing: border-box; - -webkit-appearance: none; -} -input[type="text"], input[type="search"] -{ - line-height: $spacing-vertical - 4; - height: $spacing-vertical * 1.5; -} - .truncated-text { - display: inline-block; + //display: inline-block; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; } .busy-indicator @@ -144,75 +121,6 @@ input[type="text"], input[type="search"] } } -.button-set-item { - position: relative; - display: inline-block; - - + .button-set-item - { - margin-left: $padding-button; - } -} - -.button-block, .faux-button-block -{ - display: inline-block; - height: $height-button; - line-height: $height-button; - text-decoration: none; - border: 0 none; - text-align: center; - border-radius: 2px; - text-transform: uppercase; - .icon - { - top: 0em; - } - .icon:first-child - { - padding-right: 5px; - } - .icon:last-child - { - padding-left: 5px; - } -} -.button-block -{ - cursor: pointer; -} - -.button__content { - margin: 0 $padding-button; -} - -.button-primary -{ - color: white; - background-color: $color-primary; - box-shadow: $default-box-shadow; -} -.button-alt -{ - background-color: $color-bg-alt; - box-shadow: $default-box-shadow; -} - -.button-text -{ - @include text-link(); - display: inline-block; - - .button__content { - margin: 0 $padding-text-link; - } -} -.button-text-help -{ - @include text-link(#aaa); - font-size: 0.8em; -} - .icon:only-child { position: relative; top: 0.16em; @@ -235,169 +143,17 @@ input[type="text"], input[type="search"] font-style: italic; } -.form-row -{ - + .form-row - { - margin-top: $spacing-vertical / 2; - } - .help - { - margin-top: $spacing-vertical / 2; - } - + .form-row-submit - { - margin-top: $spacing-vertical; - } -} - -.form-field-container { - display: inline-block; -} - -.form-field--text { - width: 330px; -} - -.form-field--text-number { - width: 50px; -} - -.form-field-advice-container { - position: relative; -} - -.form-field-advice { - position: absolute; - top: 0px; - left: 0px; - - display: flex; - flex-direction: column; - - white-space: nowrap; - - transition: opacity 400ms ease-in; -} - -.form-field-advice--fading { - opacity: 0; -} - -.form-field-advice__arrow { - text-align: left; - padding-left: 18px; - - font-size: 22px; - line-height: 0.3; - color: darken($color-primary, 5%); -} - - -.form-field-advice__content-container { - display: inline-block; -} - -.form-field-advice__content { - display: inline-block; - - padding: 5px; - border-radius: 2px; - - background-color: darken($color-primary, 5%); - color: #fff; -} - -.form-field-label { - width: 118px; - text-align: right; - vertical-align: top; - display: inline-block; -} - - .sort-section { display: block; - margin-bottom: 5px; + margin-bottom: $spacing-vertical * 2/3; text-align: right; + line-height: 1; font-size: 0.85em; color: $color-help; } -.modal-overlay { - position: fixed; - display: flex; - justify-content: center; - align-items: center; - - top: 0px; - left: 0px; - right: 0px; - bottom: 0px; - background-color: rgba(255, 255, 255, 0.74902); - z-index: 9999; -} - -.modal { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - border: 1px solid rgb(204, 204, 204); - background: rgb(255, 255, 255); - overflow: auto; - border-radius: 4px; - outline: none; - padding: 36px; - max-width: 250px; -} - -.modal__header { - margin-bottom: 5px; - text-align: center; -} - -.modal__buttons { - display: flex; - flex-direction: row; - justify-content: center; - margin-top: 15px; -} - -.modal__button { - margin: 0px 6px; -} - -.error-modal-overlay { - background: rgba(#000, .88); -} - -.error-modal__content { - display: flex; - padding: 0px 8px 10px 10px; -} - -.error-modal__warning-symbol { - margin-top: 6px; - margin-right: 7px; -} - -.download-started-modal__file-path { - word-break: break-all; -} - -.error-modal { - max-width: none; - width: 400px; -} -.error-modal__error-list { /*shitty hack/temp fix for long errors making modals unusable*/ - border: 1px solid #eee; - padding: 8px; - list-style: none; - max-height: 400px; - max-width: 400px; - overflow-y: hidden; +section.section-spaced { + margin-bottom: $spacing-vertical; } diff --git a/ui/scss/_icons.scss b/ui/scss/_icons.scss index 91b8255bb..441113e39 100644 --- a/ui/scss/_icons.scss +++ b/ui/scss/_icons.scss @@ -25,12 +25,6 @@ transform: translate(0, 0); } -.icon-mega -{ - font-size: 200px; - line-height: 1; -} - /* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen readers do not read off random characters that represent icons */ .icon-glass:before { diff --git a/ui/scss/_reset.scss b/ui/scss/_reset.scss index 66d0b0f1e..e951875a8 100644 --- a/ui/scss/_reset.scss +++ b/ui/scss/_reset.scss @@ -3,20 +3,24 @@ body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, code, form, fiel margin:0; padding:0; } -input:focus, textarea:focus +:focus { - outline: 0; + outline: 0; } -table +input::-webkit-search-cancel-button { + /* Remove default */ + -webkit-appearance: none; +} +table { border-collapse: collapse; border-spacing:0; } -fieldset, img, iframe +fieldset, img, iframe { border: 0; } -h1, h2, h3, h4, h5, h6 +h1, h2, h3, h4, h5, h6 { font-weight:normal; } @@ -25,11 +29,12 @@ ol, ul list-style-position: inside; > li { list-style-position: inside; } } -input, textarea, select +input, textarea, select { - font-family:inherit; - font-size:inherit; - font-weight:inherit; + font-family:inherit; + font-size:inherit; + font-weight:inherit; + border: 0 none; } img { width: auto\9; diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 6012fc3ee..7c87a5fbb 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -1,15 +1,24 @@ @import "_reset"; -@import "_grid"; @import "_icons"; @import "_mediaelement"; -@import "_canvas"; @import "_gui"; @import "component/_table"; +@import "component/_button.scss"; +@import "component/_card.scss"; @import "component/_file-actions.scss"; @import "component/_file-tile.scss"; +@import "component/_form-field.scss"; +@import "component/_header.scss"; @import "component/_menu.scss"; @import "component/_tooltip.scss"; @import "component/_load-screen.scss"; @import "component/_channel-indicator.scss"; +@import "component/_notice.scss"; +@import "component/_modal.scss"; +@import "component/_modal-page.scss"; +@import "component/_snack-bar.scss"; +@import "component/_video.scss"; @import "page/_developer.scss"; -@import "page/_watch.scss"; \ No newline at end of file +@import "page/_watch.scss"; +@import "page/_reward.scss"; +@import "page/_show.scss"; diff --git a/ui/scss/component/_button.scss b/ui/scss/component/_button.scss new file mode 100644 index 000000000..5c6fed22f --- /dev/null +++ b/ui/scss/component/_button.scss @@ -0,0 +1,87 @@ +@import "../global"; + +$button-focus-shift: 12%; + +.button-set-item { + position: relative; + display: inline-block; + + + .button-set-item + { + margin-left: $padding-button; + } +} + +.button-block, .faux-button-block +{ + display: inline-block; + height: $height-button; + line-height: $height-button; + text-decoration: none; + border: 0 none; + text-align: center; + border-radius: 2px; + text-transform: uppercase; + .icon + { + top: 0em; + } + .icon:first-child + { + padding-right: 5px; + } + .icon:last-child + { + padding-left: 5px; + } + .icon:only-child + { + padding-left: 0; + padding-right: 0; + } +} +.button-block +{ + cursor: pointer; +} + +.button__content { + margin: 0 $padding-button; +} + +.button-primary +{ + $color-button-text: white; + color: darken($color-button-text, $button-focus-shift * 0.5); + background-color: $color-primary; + box-shadow: $box-shadow-layer; + &:focus { + color: $color-button-text; + //box-shadow: $box-shadow-focus; + background-color: mix(black, $color-primary, $button-focus-shift) + } +} +.button-alt +{ + background-color: $color-bg-alt; + box-shadow: $box-shadow-layer; +} + +.button-text +{ + @include text-link(); + display: inline-block; + + .button__content { + margin: 0 $padding-text-link; + } +} +.button-text-help +{ + @include text-link(#aaa); + font-size: 0.8em; +} +.button--flat +{ + box-shadow: none !important; +} \ No newline at end of file diff --git a/ui/scss/component/_card.scss b/ui/scss/component/_card.scss new file mode 100644 index 000000000..2e325d827 --- /dev/null +++ b/ui/scss/component/_card.scss @@ -0,0 +1,151 @@ +@import "../global"; + +$padding-card-horizontal: $spacing-vertical * 2/3; + +.card { + margin-left: auto; + margin-right: auto; + max-width: $width-page-constrained; + background: $color-bg; + box-shadow: $box-shadow-layer; + border-radius: 2px; + margin-bottom: $spacing-vertical * 2/3; + overflow: auto; +} +.card--obscured +{ + position: relative; +} +.card--obscured .card__inner { + -webkit-filter: blur($blur-intensity); + -moz-filter: blur($blur-intensity); + -o-filter: blur($blur-intensity); + -ms-filter: blur($blur-intensity); + filter: blur($blur-intensity); +} +.card__title-primary { + padding: 0 $padding-card-horizontal; + margin-top: $spacing-vertical; +} +.card__title-identity { + padding: 0 $padding-card-horizontal; + margin-top: $spacing-vertical * 1/3; + margin-bottom: $spacing-vertical * 1/3; +} +.card__actions { + padding: 0 $padding-card-horizontal; +} +.card__actions { + margin-top: $spacing-vertical * 2/3; +} +.card__actions--bottom { + margin-top: $spacing-vertical * 1/3; + margin-bottom: $spacing-vertical * 1/3; +} +.card__actions--form-submit { + margin-top: $spacing-vertical; + margin-bottom: $spacing-vertical * 2/3; +} +.card__content { + margin-top: $spacing-vertical * 2/3; + margin-bottom: $spacing-vertical * 2/3; + padding: 0 $padding-card-horizontal; +} +.card__subtext { + color: #444; + margin-top: 12px; + font-size: 0.9em; + margin-top: $spacing-vertical * 2/3; + margin-bottom: $spacing-vertical * 2/3; + padding: 0 $padding-card-horizontal; +} +.card__subtext--allow-newlines { + white-space: pre-wrap; +} +.card__subtext--two-lines { + height: $font-size * 0.9 * $font-line-height * 2; +} +.card-overlay { + position: absolute; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + padding: 20px; + background-color: rgba(128, 128, 128, 0.8); + color: #fff; + display: flex; + align-items: center; + font-weight: 600; +} + +$card-link-scaling: 1.1; +.card__link { + display: block; +} +.card--link:hover { + position: relative; + z-index: 1; + box-shadow: $box-shadow-focus; + transform: scale($card-link-scaling); + transform-origin: 50% 50%; + overflow-x: visible; + overflow-y: visible; +} + +.card__media { + background-size: cover; + background-repeat: no-repeat; + background-position: 50% 50%; +} + +$width-card-small: $spacing-vertical * 12; +$height-card-small: $spacing-vertical * 15; + +.card--small { + width: $width-card-small; + overflow-x: hidden; + white-space: normal; +} +.card--small .card__media { + height: $width-card-small * 9 / 16; +} + +.card__subtitle { + color: $color-help; + font-size: 0.85em; + line-height: $font-line-height * 1 / 0.85; +} + +.card-series-submit +{ + margin-left: auto; + margin-right: auto; + max-width: $width-page-constrained; + padding: $spacing-vertical / 2; +} + +.card-row { + > .card { + vertical-align: top; + display: inline-block; + margin-right: $spacing-vertical / 3; + } + + .card-row { + margin-top: $spacing-vertical * 1/3; + } +} +.card-row--small { + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + + /*hacky way to give space for hover */ + padding-left: 20px; + margin-left: -20px; + padding-right: 20px; + margin-right: -20px; +} +.card-row__header { + margin-bottom: $spacing-vertical / 3; +} \ No newline at end of file diff --git a/ui/scss/component/_channel-indicator.scss b/ui/scss/component/_channel-indicator.scss index 06446e23f..52a0baed6 100644 --- a/ui/scss/component/_channel-indicator.scss +++ b/ui/scss/component/_channel-indicator.scss @@ -1,5 +1,5 @@ @import "../global"; .channel-indicator__icon--invalid { - color: #b01c2e; + color: $color-error; } diff --git a/ui/scss/component/_file-tile.scss b/ui/scss/component/_file-tile.scss index a5c73a175..433abc746 100644 --- a/ui/scss/component/_file-tile.scss +++ b/ui/scss/component/_file-tile.scss @@ -1,31 +1,26 @@ @import "../global"; +$height-file-tile: $spacing-vertical * 6; .file-tile__row { - height: $spacing-vertical * 7; -} - -.file-tile__row--unavailable { - opacity: 0.5; -} - -.file-tile__thumbnail { - max-width: 100%; - max-height: $spacing-vertical * 7; - display: block; - margin-left: auto; - margin-right: auto; -} - -.file-tile__title { - font-weight: bold; -} - -.file-tile__cost { - float: right; -} - -.file-tile__description { - color: #444; - margin-top: 12px; - font-size: 0.9em; + overflow: hidden; + height: $height-file-tile; + .credit-amount { + float: right; + } + //also a hack + .card__media { + height: $height-file-tile; + max-width: $height-file-tile; + width: $height-file-tile; + margin-right: $spacing-vertical / 2; + float: left; + } + //basically everything here is a hack now + .file-tile__content { + padding-top: $spacing-vertical * 1/3; + margin-left: $height-file-tile + $spacing-vertical / 2; + } + .card__title-primary { + margin-top: 0; + } } \ No newline at end of file diff --git a/ui/scss/component/_form-field.scss b/ui/scss/component/_form-field.scss new file mode 100644 index 000000000..fa2b36e27 --- /dev/null +++ b/ui/scss/component/_form-field.scss @@ -0,0 +1,158 @@ +@import "../global"; + +$width-input-border: 2px; +$width-input-text: 330px; + +.form-row-submit +{ + margin-top: $spacing-vertical; +} +.form-row-submit--with-footer +{ + margin-bottom: $spacing-vertical; +} + +.form-row__label-row { + margin-top: $spacing-vertical * 5/6; + margin-bottom: $spacing-vertical * 1/6; + line-height: 1; + font-size: 0.9 * $font-size; +} +.form-row__label-row--prefix { + float: left; + margin-right: 5px; +} + +input[type="text"].input-copyable { + border: 1px solid $color-form-border; + line-height: 1; + padding-top: $spacing-vertical * 1/3; + padding-bottom: $spacing-vertical * 1/3; + width: $width-input-text; + padding-left: 5px; + padding-right: 5px; + width: 100%; + &:focus { border-color: black; } +} + +.form-field { + display: inline-block; + + input[type="checkbox"], + input[type="radio"] { + cursor: pointer; + } + + select { + transition: outline $transition-standard; + cursor: pointer; + box-sizing: border-box; + padding-left: 5px; + padding-right: 5px; + height: $spacing-vertical; + &:focus { + outline: $width-input-border solid $color-primary; + } + } + + textarea, + input[type="text"], + input[type="password"], + input[type="email"], + input[type="number"], + input[type="search"], + input[type="date"] { + @include placeholder { + color: lighten($color-text-dark, 60%); + } + transition: all $transition-standard; + cursor: pointer; + padding-left: 1px; + padding-right: 1px; + box-sizing: border-box; + -webkit-appearance: none; + &[readonly] { + background-color: #bbb; + } + } + + input[type="text"], + input[type="password"], + input[type="email"], + input[type="number"], + input[type="search"], + input[type="date"] { + border-bottom: $width-input-border solid $color-form-border; + line-height: 1; + padding-top: $spacing-vertical * 1/3; + padding-bottom: $spacing-vertical * 1/3; + &.form-field__input--error { + border-color: $color-error; + } + &.form-field__input--inline { + padding-top: 0; + padding-bottom: 0; + border-bottom-width: 1px; + margin-left: 8px; + margin-right: 8px; + } + } + + textarea:focus, + input[type="text"]:focus, + input[type="password"]:focus, + input[type="email"]:focus, + input[type="number"]:focus, + input[type="search"]:focus, + input[type="date"]:focus { + border-color: $color-primary; + } + + textarea { + border: $width-input-border solid $color-form-border; + } +} + +.form-field__label { + &[for] { cursor: pointer; } + > input[type="checkbox"], input[type="radio"] { + margin-right: 6px; + } +} + +.form-field__label--error { + color: $color-error; +} + +.form-field__input-text { + width: $width-input-text; +} + +.form-field__prefix { + margin-right: 4px; +} +.form-field__postfix { + margin-left: 4px; +} + +.form-field__input-number { + width: 70px; + text-align: right; +} + +.form-field__input-textarea { + width: $width-input-text; +} + +.form-field__error, .form-field__helper { + margin-top: $spacing-vertical * 1/3; + font-size: 0.8em; + transition: opacity $transition-standard; +} + +.form-field__error { + color: $color-error; +} +.form-field__helper { + color: $color-help; +} \ No newline at end of file diff --git a/ui/scss/component/_header.scss b/ui/scss/component/_header.scss new file mode 100644 index 000000000..0071f01f9 --- /dev/null +++ b/ui/scss/component/_header.scss @@ -0,0 +1,94 @@ +@import "../global"; + +$color-header: #666; +$color-header-active: darken($color-header, 20%); + +#header +{ + color: $color-header; + background: #fff; + display: flex; + position: fixed; + box-shadow: $box-shadow-layer; + top: 0; + left: 0; + width: 100%; + z-index: 2; + padding: $spacing-vertical / 2; + box-sizing: border-box; +} +.header__item { + flex: 0 0 content; + padding-left: $spacing-vertical / 4; + padding-right: $spacing-vertical / 4; +} +.header__item--wunderbar { + flex-grow: 1; +} + +.wunderbar +{ + position: relative; + .icon { + position: absolute; + left: 10px; + top: $spacing-vertical / 2 - 4px; //hacked + } +} + +.wunderbar--active .icon-search { color: $color-primary; } + +.wunderbar__input { + background: rgba(255, 255, 255, 0.7); + width: 100%; + color: $color-header; + height: $spacing-vertical * 1.5; + line-height: $spacing-vertical * 1.5; + padding-left: 38px; + padding-right: 5px; + border: 1px solid $color-text-dark; + @include border-radius(2px); + border: 1px solid #ccc; + &:focus { + color: $color-header-active; + box-shadow: $box-shadow-focus; + border-color: $color-primary; + } +} + +nav.sub-header +{ + text-transform: uppercase; + padding: 0 0 $spacing-vertical; + &.sub-header--constrained { + max-width: $width-page-constrained; + margin-left: auto; + margin-right: auto; + } + > a + { + $sub-header-selected-underline-height: 2px; + display: inline-block; + margin: 0 15px; + padding: 0 5px; + line-height: $height-header - $spacing-vertical - $sub-header-selected-underline-height; + color: $color-header; + &:first-child + { + margin-left: 0; + } + &:last-child + { + margin-right: 0; + } + &.sub-header-selected + { + border-bottom: $sub-header-selected-underline-height solid $color-header-active; + color: $color-header-active; + } + &:hover + { + color: $color-header-active; + } + } +} \ No newline at end of file diff --git a/ui/scss/component/_load-screen.scss b/ui/scss/component/_load-screen.scss index e56eb12c0..0caa74f65 100644 --- a/ui/scss/component/_load-screen.scss +++ b/ui/scss/component/_load-screen.scss @@ -23,7 +23,7 @@ } .load-screen__details--warning { - color: $color-warning; + color: white; } .load-screen__cancel-link { diff --git a/ui/scss/component/_menu.scss b/ui/scss/component/_menu.scss index e3b0566c4..d8e79be28 100644 --- a/ui/scss/component/_menu.scss +++ b/ui/scss/component/_menu.scss @@ -10,7 +10,7 @@ $border-radius-menu: 2px; position: absolute; white-space: nowrap; background-color: white; - box-shadow: $default-box-shadow; + box-shadow: $box-shadow-layer; border-radius: $border-radius-menu; padding-top: ($spacing-vertical / 5) 0px; z-index: 1; diff --git a/ui/scss/component/_modal-page.scss b/ui/scss/component/_modal-page.scss new file mode 100644 index 000000000..ada366f61 --- /dev/null +++ b/ui/scss/component/_modal-page.scss @@ -0,0 +1,54 @@ +@import "../global"; + +.modal-page { + position: fixed; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border: 1px solid rgb(204, 204, 204); + background: rgb(255, 255, 255); + overflow: auto; +} + +.modal-page--full { + left: 0; + right: 0; + top: 0; + bottom: 0; + .modal-page__content { + max-width: 500px; + } +} + +/* +.modal-page { + position: fixed; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border: 1px solid rgb(204, 204, 204); + background: rgb(255, 255, 255); + overflow: auto; + border-radius: 4px; + outline: none; + padding: 36px; + + top: 25px; + left: 25px; + right: 25px; + bottom: 25px; +} +*/ + +.modal-page__content { + h1, h2 { + margin-bottom: $spacing-vertical / 2; + } + h3, h4 { + margin-bottom: $spacing-vertical / 4; + } +} \ No newline at end of file diff --git a/ui/scss/component/_modal.scss b/ui/scss/component/_modal.scss new file mode 100644 index 000000000..05d5e8de1 --- /dev/null +++ b/ui/scss/component/_modal.scss @@ -0,0 +1,81 @@ +@import "../global"; + +.modal-overlay { + position: fixed; + display: flex; + justify-content: center; + align-items: center; + + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + background-color: rgba(255, 255, 255, 0.74902); + z-index: 9999; +} + +.modal-overlay--clear { + background-color: transparent; +} + +.modal { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + border: 1px solid rgb(204, 204, 204); + background: rgb(255, 255, 255); + overflow: auto; + border-radius: 4px; + padding: $spacing-vertical; + box-shadow: $box-shadow-layer; + max-width: 400px; +} + +.modal__header { + margin-bottom: 5px; + text-align: center; +} + +.modal__buttons { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 15px; +} + +.modal__button { + margin: 0px 6px; +} + +.error-modal-overlay { + background: rgba(#000, .88); +} + +.error-modal__content { + display: flex; + padding: 0px 8px 10px 10px; +} + +.error-modal__warning-symbol { + margin-top: 6px; + margin-right: 7px; +} + +.download-started-modal__file-path { + word-break: break-all; +} + +.error-modal { + max-width: none; + width: 400px; +} +.error-modal__error-list { /*shitty hack/temp fix for long errors making modals unusable*/ + border: 1px solid #eee; + padding: 8px; + list-style: none; + max-height: 400px; + max-width: 400px; + overflow-y: hidden; +} \ No newline at end of file diff --git a/ui/scss/component/_notice.scss b/ui/scss/component/_notice.scss new file mode 100644 index 000000000..b77ba2a5a --- /dev/null +++ b/ui/scss/component/_notice.scss @@ -0,0 +1,18 @@ +@import "../global"; + +.notice { + padding: 10px 20px; + border: 1px solid #000; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + border-radius: 5px; + + color: #468847; + background-color: #dff0d8; + border-color: #d6e9c6; +} + +.notice--error { + color: #b94a48; + background-color: #f2dede; + border-color: #eed3d7; +} diff --git a/ui/scss/component/_snack-bar.scss b/ui/scss/component/_snack-bar.scss new file mode 100644 index 000000000..c3df3ab92 --- /dev/null +++ b/ui/scss/component/_snack-bar.scss @@ -0,0 +1,42 @@ +@import "../global"; + +$padding-snack-horizontal: $spacing-vertical; + +.snack-bar { + $height-snack: $spacing-vertical * 2; + $padding-snack-vertical: $spacing-vertical / 4; + + line-height: $height-snack - $padding-snack-vertical * 2; + padding: $padding-snack-vertical $padding-snack-horizontal; + position: fixed; + top: $spacing-vertical; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + min-width: 300px; + max-width: 500px; + background: $color-black-transparent; + color: #f0f0f0; + + display: flex; + justify-content: space-between; + align-items: center; + + border-radius: 2px; + + transition: all $transition-standard; + + z-index: 10000; /*hack to get it over react modal */ +} + +.snack-bar__action { + display: inline-block; + text-transform: uppercase; + color: $color-primary-light; + margin: 0px 0px 0px $padding-snack-horizontal; + min-width: min-content; + &:hover { + text-decoration: underline; + } +} diff --git a/ui/scss/component/_tooltip.scss b/ui/scss/component/_tooltip.scss index 9a6ccd7da..0be9b1db8 100644 --- a/ui/scss/component/_tooltip.scss +++ b/ui/scss/component/_tooltip.scss @@ -15,6 +15,7 @@ z-index: 1; left: 50%; margin-left: $tooltip-body-width * -1 / 2; + white-space: normal; box-sizing: border-box; padding: $spacing-vertical / 2; @@ -24,7 +25,7 @@ background-color: $color-bg; font-size: $font-size * 7/8; line-height: $font-line-height; - box-shadow: $default-box-shadow; + box-shadow: $box-shadow-layer; } .tooltip--header .tooltip__link { diff --git a/ui/scss/component/_video.scss b/ui/scss/component/_video.scss new file mode 100644 index 000000000..9dd95ebe9 --- /dev/null +++ b/ui/scss/component/_video.scss @@ -0,0 +1,56 @@ +video { + object-fit: contain; + box-sizing: border-box; + max-height: 100%; + max-width: 100%; +} + +.video { + background: #000; + color: white; +} + + +.video-embedded { + max-width: $width-page-constrained; + max-height: $height-video-embedded; + height: $height-video-embedded; + video { + height: 100%; + } + &.video--hidden { + height: $height-video-embedded; + } + &.video--active { + /*background: none;*/ + } +} + +.video__cover { + text-align: center; + height: 100%; + width: 100%; + background-size: auto 100%; + background-position: center center; + background-repeat: no-repeat; + position: relative; + .video__play-button { @include absolute-center(); } +} +.video__play-button { + position: absolute; + width: 100%; + height: 100%; + cursor: pointer; + display: none; + font-size: $spacing-vertical * 3; + color: white; + z-index: 1; + background: $color-black-transparent; + opacity: 0.6; + left: 0; + top: 0; + &:hover { + opacity: 1; + transition: opacity $transition-standard; + } +} \ No newline at end of file diff --git a/ui/scss/page/_reward.scss b/ui/scss/page/_reward.scss new file mode 100644 index 000000000..a550c01c3 --- /dev/null +++ b/ui/scss/page/_reward.scss @@ -0,0 +1,5 @@ +@import "../global"; + +.reward-page__details { + background-color: lighten($color-canvas, 1.5%); +} \ No newline at end of file diff --git a/ui/scss/page/_show.scss b/ui/scss/page/_show.scss new file mode 100644 index 000000000..48b82d065 --- /dev/null +++ b/ui/scss/page/_show.scss @@ -0,0 +1,9 @@ +@import "../global"; + +.show-page-media { + text-align: center; + margin-bottom: $spacing-vertical; + img { + max-width: 100%; + } +} \ No newline at end of file diff --git a/ui/scss/page/_watch.scss b/ui/scss/page/_watch.scss index 23fbcc171..6ed5459ae 100644 --- a/ui/scss/page/_watch.scss +++ b/ui/scss/page/_watch.scss @@ -1,6 +1,3 @@ -.video { - background: #000; -} .video__overlay { position: absolute; @@ -23,7 +20,7 @@ } .video__back-label { - opacity: 0; + opacity: 0.5; transition: opacity 100ms ease-in; } diff --git a/ui/webpack.config.js b/ui/webpack.config.js index fdafd70b2..be349baa9 100644 --- a/ui/webpack.config.js +++ b/ui/webpack.config.js @@ -35,4 +35,4 @@ module.exports = { ] }, target: 'electron-main', -}; +}; \ No newline at end of file diff --git a/ui/webpack.dev.config.js b/ui/webpack.dev.config.js index b84f2f94a..358d6c04d 100644 --- a/ui/webpack.dev.config.js +++ b/ui/webpack.dev.config.js @@ -38,4 +38,4 @@ module.exports = { ] }, target: 'electron-main', -}; +}; \ No newline at end of file