Merge remote-tracking branch 'origin/channels'
* origin/channels: Publish: add ability to choose and create channels Add new channel auth status indicator to file tiles Add channel indicator component Add functions for building and parsing new-style URIs
This commit is contained in:
commit
f83ac641c6
8 changed files with 290 additions and 27 deletions
43
ui/js/component/channel-indicator.js
Normal file
43
ui/js/component/channel-indicator.js
Normal file
|
@ -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 (
|
||||
<span>
|
||||
by <strong>{channelUri}</strong> {' '}
|
||||
<Icon icon={icon} className={`channel-indicator__icon channel-indicator__icon--${modifier}`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ChannelIndicator;
|
|
@ -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 (
|
||||
<div>
|
||||
{this.props.metadata.content_type && this.props.metadata.content_type.startsWith('video/')
|
||||
{this.props.contentType && this.props.contentType.startsWith('video/')
|
||||
? <WatchLink streamName={this.props.streamName} downloadStarted={!!this.state.fileInfo} />
|
||||
: 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 (<section className="file-actions">
|
||||
{
|
||||
fileInfo || this.state.available || this.state.forceShowActions
|
||||
? <FileActionsRow outpoint={this.props.outpoint} metadata={this.props.metadata} streamName={this.props.streamName} />
|
||||
? <FileActionsRow outpoint={this.props.outpoint} metadata={this.props.metadata} streamName={this.props.streamName}
|
||||
contentType={this.props.contentType} />
|
||||
: <div>
|
||||
<div className="button-set-item empty">This file is not currently available.</div>
|
||||
<ToolTip label="Why?"
|
||||
|
|
|
@ -3,12 +3,13 @@ import lbry from '../lbry.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: {
|
||||
name: React.PropTypes.string
|
||||
uri: React.PropTypes.string
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -21,7 +22,7 @@ let FilePrice = React.createClass({
|
|||
componentDidMount: function() {
|
||||
this._isMounted = true;
|
||||
|
||||
lbry.getCostInfoForName(this.props.name, ({cost, includesData}) => {
|
||||
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 (
|
||||
<section className={ 'file-tile card ' + (obscureNsfw ? 'card-obscured ' : '') } onMouseEnter={this.handleMouseOver} onMouseLeave={this.handleMouseOut}>
|
||||
<div className={"row-fluid card-content file-tile__row"}>
|
||||
<div className="span3">
|
||||
<a href={'?show=' + this.props.name}><Thumbnail className="file-tile__thumbnail" src={metadata.thumbnail} alt={'Photo for ' + (title || this.props.name)} /></a>
|
||||
<a href={'?show=' + this.props.uri}><Thumbnail className="file-tile__thumbnail" src={metadata.thumbnail} alt={'Photo for ' + (title || this.props.uri)} /></a>
|
||||
</div>
|
||||
<div className="span9">
|
||||
{ !this.props.hidePrice
|
||||
? <FilePrice name={this.props.name} />
|
||||
? <FilePrice uri={this.props.uri} />
|
||||
: null}
|
||||
<div className="meta"><a href={'?show=' + this.props.name}>{'lbry://' + this.props.name}</a></div>
|
||||
<div className="meta"><a href={'?show=' + this.props.uri}>{'lbry://' + this.props.uri}</a></div>
|
||||
<h3 className="file-tile__title">
|
||||
<a href={'?show=' + this.props.name}>
|
||||
<a href={'?show=' + this.props.uri}>
|
||||
<TruncatedText lines={1}>
|
||||
{title}
|
||||
</TruncatedText>
|
||||
</a>
|
||||
</h3>
|
||||
<FileActions streamName={this.props.name} outpoint={this.props.outpoint} metadata={metadata} />
|
||||
<ChannelIndicator uri={this.props.uri} claimInfo={this.props.claimInfo} />
|
||||
<FileActions uri={this.props.uri} outpoint={this.props.outpoint} metadata={metadata} contentType={this._contentType} />
|
||||
<p className="file-tile__description">
|
||||
<TruncatedText lines={3}>
|
||||
{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 <FileTileStream outpoint={this.state.outpoint} metadata={this.state.metadata} {... this.props} />;
|
||||
return <FileTileStream outpoint={this.state.outpoint} claimInfo={this.state.claimInfo}
|
||||
{... this.props} uri={this.props.uri}/>;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -48,7 +48,7 @@ var SearchResults = React.createClass({
|
|||
if (!seenNames[name]) {
|
||||
seenNames[name] = name;
|
||||
rows.push(
|
||||
<FileTile key={name} name={name} sdHash={value.sources.lbry_sd_hash} />
|
||||
<FileTile key={name} uri={name} sdHash={value.sources.lbry_sd_hash} />
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -84,18 +84,18 @@ var FeaturedContent = React.createClass({
|
|||
<div className="row-fluid">
|
||||
<div className="span6">
|
||||
<h3>Featured Content</h3>
|
||||
{ this.state.featuredNames.map((name) => { return <FileTile key={name} name={name} /> }) }
|
||||
{ this.state.featuredNames.map((name) => { return <FileTile key={name} uri={name} /> }) }
|
||||
</div>
|
||||
<div className="span6">
|
||||
<h3>
|
||||
Community Content
|
||||
<ToolTip label="What's this?" body={toolTipText} className="tooltip--header"/>
|
||||
</h3>
|
||||
<FileTile name="one" />
|
||||
<FileTile name="two" />
|
||||
<FileTile name="three" />
|
||||
<FileTile name="four" />
|
||||
<FileTile name="five" />
|
||||
<FileTile uri="one" />
|
||||
<FileTile uri="two" />
|
||||
<FileTile uri="three" />
|
||||
<FileTile uri="four" />
|
||||
<FileTile uri="five" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
<main ref="page">
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
|
@ -280,6 +342,26 @@ var PublishPage = React.createClass({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h4>Channel</h4>
|
||||
<div className="form-row">
|
||||
<FormField type="select" onChange={this.handleChannelChange} value={this.state.channel}>
|
||||
<option key="none" value="none">None</option>
|
||||
{this.state.channels.map(({name}) => <option key={name} value={name}>{name}</option>)}
|
||||
<option key="new" value="new">New channel...</option>
|
||||
</FormField>
|
||||
{this.state.channel == 'new'
|
||||
? <section>
|
||||
<label>Name <FormField type="text" onChange={this.handleNewChannelNameChange} ref={newChannelName => { this.refs.newChannelName = newChannelName }}
|
||||
value={this.state.newChannelName} /></label>
|
||||
<label>Bid amount <FormField type="text-number" onChange={this.handleNewChannelBidChange} value={this.state.newChannelBid} /> LBC</label>
|
||||
<Link button="primary" label={!this.state.creatingChannel ? 'Create channel' : 'Creating channel...'} onClick={this.handleCreateChannelClick} disabled={this.state.creatingChannel} />
|
||||
</section>
|
||||
: null}
|
||||
<div className="help">What channel would you like to publish this file under?</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h4>Choose File</h4>
|
||||
<FormField name="file" ref="file" type="file" />
|
||||
|
|
118
ui/js/uri.js
Normal file
118
ui/js/uri.js
Normal file
|
@ -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;
|
|
@ -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";
|
5
ui/scss/component/_channel-indicator.scss
Normal file
5
ui/scss/component/_channel-indicator.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import "../global";
|
||||
|
||||
.channel-indicator__icon--invalid {
|
||||
color: #b01c2e;
|
||||
}
|
Loading…
Reference in a new issue