From af6981c164898436e702d7aa4ea1d866a8be7c6d Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Tue, 4 Apr 2017 17:27:14 -0400 Subject: [PATCH 1/4] Add functions for building and parsing new-style URIs --- ui/js/uri.js | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 ui/js/uri.js diff --git a/ui/js/uri.js b/ui/js/uri.js new file mode 100644 index 000000000..98883c6be --- /dev/null +++ b/ui/js/uri.js @@ -0,0 +1,118 @@ +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; From 3e2b675e7b5479e1ce36a571877cff2fecaace43 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sat, 8 Apr 2017 08:25:38 -0400 Subject: [PATCH 2/4] Add channel indicator component --- ui/js/component/channel-indicator.js | 43 +++++++++++++++++++++++ ui/scss/all.scss | 1 + ui/scss/component/_channel-indicator.scss | 5 +++ 3 files changed, 49 insertions(+) create mode 100644 ui/js/component/channel-indicator.js create mode 100644 ui/scss/component/_channel-indicator.scss diff --git a/ui/js/component/channel-indicator.js b/ui/js/component/channel-indicator.js new file mode 100644 index 000000000..674484200 --- /dev/null +++ b/ui/js/component/channel-indicator.js @@ -0,0 +1,43 @@ +import React from 'react'; +import lbry from '../lbry.js'; +import uri from '../uri.js'; +import {Icon} from './common.js'; + +const ChannelIndicator = React.createClass({ + propTypes: { + uri: React.PropTypes.string.isRequired, + claimInfo: React.PropTypes.object.isRequired, + }, + 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 channelUriObj = Object.assign({}, uriObj); + delete channelUriObj.path; + const channelUri = uri.buildLbryUri(channelUriObj, false); + + let icon, modifier; + if (!signature_is_valid) { + icon = 'icon-check-circle'; + modifier = 'valid'; + } else { + icon = 'icon-times-circle'; + modifier = 'invalid'; + } + return ( + + by {channelUri} {' '} + + + ); + } +}); + +export default ChannelIndicator; \ No newline at end of file diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 8729eb666..6012fc3ee 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -10,5 +10,6 @@ @import "component/_menu.scss"; @import "component/_tooltip.scss"; @import "component/_load-screen.scss"; +@import "component/_channel-indicator.scss"; @import "page/_developer.scss"; @import "page/_watch.scss"; \ No newline at end of file diff --git a/ui/scss/component/_channel-indicator.scss b/ui/scss/component/_channel-indicator.scss new file mode 100644 index 000000000..06446e23f --- /dev/null +++ b/ui/scss/component/_channel-indicator.scss @@ -0,0 +1,5 @@ +@import "../global"; + +.channel-indicator__icon--invalid { + color: #b01c2e; +} From 461f5f95d94651c5d66b7927f111b3eb35022dcb Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sat, 8 Apr 2017 02:34:51 -0400 Subject: [PATCH 3/4] Add new channel auth status indicator to file tiles --- ui/js/component/file-actions.js | 7 +++-- ui/js/component/file-tile.js | 47 ++++++++++++++++++++------------- ui/js/page/discover.js | 14 +++++----- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/ui/js/component/file-actions.js b/ui/js/component/file-actions.js index c8331e261..92d787a21 100644 --- a/ui/js/component/file-actions.js +++ b/ui/js/component/file-actions.js @@ -70,6 +70,7 @@ let FileActionsRow = React.createClass({ streamName: React.PropTypes.string, outpoint: React.PropTypes.string.isRequired, metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]), + contentType: React.PropTypes.string, }, getInitialState: function() { return { @@ -197,7 +198,7 @@ let FileActionsRow = React.createClass({ return (
- {this.props.metadata.content_type && this.props.metadata.content_type.startsWith('video/') + {this.props.contentType && this.props.contentType.startsWith('video/') ? : null} {this.state.fileInfo !== null || this.state.fileInfo.isMine @@ -236,6 +237,7 @@ export let FileActions = React.createClass({ streamName: React.PropTypes.string, outpoint: React.PropTypes.string.isRequired, metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]), + contentType: React.PropTypes.string, }, getInitialState: function() { return { @@ -289,7 +291,8 @@ export let FileActions = React.createClass({ return (
{ fileInfo || this.state.available || this.state.forceShowActions - ? + ? :
This file is not currently available.
{ + lbry.getCostInfoForName(this.props.uri, ({cost, includesData}) => { if (this._isMounted) { this.setState({ cost: cost, @@ -55,9 +56,11 @@ let FilePrice = React.createClass({ export let FileTileStream = React.createClass({ _fileInfoSubscribeId: null, _isMounted: null, + _metadata: null, propTypes: { - metadata: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.object]), + uri: React.PropTypes.string, + claimInfo: React.PropTypes.object, outpoint: React.PropTypes.string, hideOnRemove: React.PropTypes.bool, hidePrice: React.PropTypes.bool, @@ -76,6 +79,11 @@ export let FileTileStream = React.createClass({ hidePrice: 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) { @@ -95,7 +103,7 @@ export let FileTileStream = React.createClass({ } }, handleMouseOver: function() { - if (this.props.obscureNsfw && this.props.metadata && this.props.metadata.nsfw) { + if (this.props.obscureNsfw && this.props.metadata && this._metadata.nsfw) { this.setState({ showNsfwHelp: true, }); @@ -113,29 +121,30 @@ export let FileTileStream = React.createClass({ return null; } - const metadata = this.props.metadata; + const metadata = this._metadata; const isConfirmed = typeof metadata == 'object'; - const title = isConfirmed ? metadata.title : ('lbry://' + this.props.name); + const title = isConfirmed ? metadata.title : ('lbry://' + this.props.uri); const obscureNsfw = this.props.obscureNsfw && isConfirmed && metadata.nsfw; return (
- +
{ !this.props.hidePrice - ? + ? : null} - +

- + {title}

- + +

{isConfirmed @@ -162,26 +171,27 @@ export let FileTile = React.createClass({ _isMounted: false, propTypes: { - name: React.PropTypes.string.isRequired, + uri: React.PropTypes.string.isRequired, available: React.PropTypes.bool, }, getInitialState: function() { return { outpoint: null, - metadata: null + claimInfo: null } }, componentDidMount: function() { this._isMounted = true; - lbry.claim_show({name: this.props.name}).then(({value, txid, nout}) => { - if (this._isMounted && value) { + lbry.resolve({uri: this.props.uri}).then(({claim: claimInfo}) => { + const {value: {stream: {metadata}}, txid, nout} = claimInfo; + if (this._isMounted && claimInfo.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, - metadata: value, + claimInfo: claimInfo, }); } }); @@ -190,10 +200,11 @@ export let FileTile = React.createClass({ this._isMounted = false; }, render: function() { - if (!this.state.metadata || !this.state.outpoint) { + if (!this.state.claimInfo || !this.state.outpoint) { return null; } - return ; + return ; } }); diff --git a/ui/js/page/discover.js b/ui/js/page/discover.js index c01cbe75b..762c55d3c 100644 --- a/ui/js/page/discover.js +++ b/ui/js/page/discover.js @@ -48,7 +48,7 @@ var SearchResults = React.createClass({ if (!seenNames[name]) { seenNames[name] = name; rows.push( - + ); } }); @@ -84,18 +84,18 @@ var FeaturedContent = React.createClass({

Featured Content

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

Community Content

- - - - - + + + + +
); From f91653ff2d8396084cfbed715973f6c401bf4b56 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sat, 8 Apr 2017 08:30:17 -0400 Subject: [PATCH 4/4] Publish: add ability to choose and create channels --- ui/js/page/publish.js | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/ui/js/page/publish.js b/ui/js/page/publish.js index e090d19f3..61773fb01 100644 --- a/ui/js/page/publish.js +++ b/ui/js/page/publish.js @@ -67,6 +67,7 @@ var PublishPage = React.createClass({ name: this.state.name, bid: parseFloat(this.state.bid), metadata: metadata, + ... this.state.channel != 'new' && this.state.channel != 'none' ? {channel_name: this.state.channel} : {}, }; if (this.refs.file.getValue() !== '') { @@ -96,11 +97,15 @@ var PublishPage = React.createClass({ }, getInitialState: function() { return { + channels: null, rawName: '', name: '', bid: '', feeAmount: '', feeCurrency: 'USD', + channel: 'none', + newChannelName: '@', + newChannelBid: '', nameResolved: false, topClaimValue: 0.0, myClaimValue: 0.0, @@ -113,6 +118,7 @@ var PublishPage = React.createClass({ uploaded: false, errorMessage: null, submitting: false, + creatingChannel: false, modal: null, }; }, @@ -247,6 +253,51 @@ var PublishPage = React.createClass({ otherLicenseUrl: event.target.value, }); }, + handleChannelChange: function (event) { + const channel = event.target.value; + + this.setState({ + channel: channel, + }); + }, + handleNewChannelNameChange: function (event) { + 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.'); + return; + } + + this.setState({ + newChannelName: newChannelName, + }); + }, + handleNewChannelBidChange: function (event) { + this.setState({ + newChannelBid: event.target.value, + }); + }, + handleCreateChannelClick: function (event) { + this.setState({ + creatingChannel: true, + }); + + lbry.channel_new({channel_name: this.state.newChannelName, amount: parseInt(this.state.newChannelBid)}).then(() => { + this.setState({ + creatingChannel: false, + }); + + this.forceUpdate(); + this.setState({ + channel: name, + }); + }, (error) => { + // TODO: add error handling + this.setState({ + creatingChannel: false, + }); + }); + }, getLicenseUrl: function() { if (!this.refs.meta_license) { return ''; @@ -256,6 +307,13 @@ var PublishPage = React.createClass({ return this.refs.meta_license.getSelectedElement().getAttribute('data-url') || '' ; } }, + componentWillMount: function() { + lbry.channel_list_mine().then((channels) => { + this.setState({ + channels: channels, + }); + }); + }, componentDidMount: function() { document.title = "Publish"; }, @@ -263,6 +321,10 @@ var PublishPage = React.createClass({ }, // Also getting a type warning here too render: function() { + if (this.state.channels === null) { + return null; + } + return (
@@ -280,6 +342,26 @@ var PublishPage = React.createClass({
+
+

Channel

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

Choose File