diff --git a/README.md b/README.md index 6098415637..a0ba5a3c8f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ cp steem-example.json steem-dev.json (note: it's steem.json in production) #### Install mysql server - + OS X : ```bash @@ -55,12 +55,23 @@ sudo apt-get update sudo apt-get install mysql-server ``` +On Ubuntu 16.04+ you may be unable to connect to mysql without root access, if +so update the mysql root user as follows:: + +``` +sudo mysql -u root +DROP USER 'root'@'localhost'; +CREATE USER 'root'@'%' IDENTIFIED BY ''; +GRANT ALL PRIVILEGES ON *.* TO 'root'@'%'; +FLUSH PRIVILEGES; +``` + Now launch mysql client and create steemit_dev database: ```bash mysql -u root > create database steemit_dev; ``` - + Install `sequelize-cli` globally: ```bash diff --git a/app/assets/icons/calendar.svg b/app/assets/icons/calendar.svg new file mode 100644 index 0000000000..60ec762d45 --- /dev/null +++ b/app/assets/icons/calendar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/assets/icons/eye.svg b/app/assets/icons/eye.svg new file mode 100644 index 0000000000..4b83a783f6 --- /dev/null +++ b/app/assets/icons/eye.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/app/assets/icons/location.svg b/app/assets/icons/location.svg new file mode 100644 index 0000000000..d7844b23b6 --- /dev/null +++ b/app/assets/icons/location.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/components/App.jsx b/app/components/App.jsx index 45f885f7b6..89f0d1fc16 100644 --- a/app/components/App.jsx +++ b/app/components/App.jsx @@ -16,6 +16,7 @@ import Icon from 'app/components/elements/Icon'; import {key_utils} from 'shared/ecc'; import MiniHeader from 'app/components/modules/MiniHeader'; import { translate } from '../Translator.js'; +import PageViewsCounter from 'app/components/elements/PageViewsCounter'; class App extends React.Component { constructor(props) { @@ -31,7 +32,6 @@ class App extends React.Component { } componentDidMount() { - require('fastclick').attach(document.body); // setTimeout(() => this.setState({showCallout: false}), 15000); } @@ -243,6 +243,7 @@ class App extends React.Component { + } } diff --git a/app/components/all.scss b/app/components/all.scss index 6db96bf39f..f3127ad2a2 100644 --- a/app/components/all.scss +++ b/app/components/all.scss @@ -27,6 +27,8 @@ @import "./elements/Reblog"; @import "./elements/YoutubePreview"; @import "./elements/SignupProgressBar"; +@import "./elements/ShareMenu"; +@import "./elements/Author"; // modules @import "./modules/Header"; diff --git a/app/components/cards/Comment.jsx b/app/components/cards/Comment.jsx index 2bfbc758b2..7985c77313 100644 --- a/app/components/cards/Comment.jsx +++ b/app/components/cards/Comment.jsx @@ -9,13 +9,12 @@ import { connect } from 'react-redux'; import { Link } from 'react-router'; import user from 'app/redux/User'; import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper'; -import Icon from 'app/components/elements/Icon'; import Userpic from 'app/components/elements/Userpic'; import transaction from 'app/redux/Transaction' import {List} from 'immutable' import { translate } from 'app/Translator'; -export function sortComments( g, comments, sort_order ) { +export function sortComments( cont, comments, sort_order ) { function netNegative(a) { return a.get("net_rshares") < 0; @@ -24,8 +23,8 @@ export function sortComments( g, comments, sort_order ) { let sort_orders = { /** sort replies by active */ active: (a,b) => { - let acontent = g.get('content').get(a); - let bcontent = g.get('content').get(b); + let acontent = cont.get(a); + let bcontent = cont.get(b); if (netNegative(acontent)) { return 1; } else if (netNegative(bcontent)) { @@ -36,8 +35,8 @@ export function sortComments( g, comments, sort_order ) { return bactive - aactive; }, update: (a,b) => { - let acontent = g.get('content').get(a); - let bcontent = g.get('content').get(b); + let acontent = cont.get(a); + let bcontent = cont.get(b); if (netNegative(acontent)) { return 1; } else if (netNegative(bcontent)) { @@ -48,8 +47,8 @@ export function sortComments( g, comments, sort_order ) { return bactive.getTime() - aactive.getTime(); }, new: (a,b) => { - let acontent = g.get('content').get(a); - let bcontent = g.get('content').get(b); + let acontent = cont.get(a); + let bcontent = cont.get(b); if (netNegative(acontent)) { return 1; } else if (netNegative(bcontent)) { @@ -60,8 +59,8 @@ export function sortComments( g, comments, sort_order ) { return bactive - aactive; }, trending: (a,b) => { - let acontent = g.get('content').get(a); - let bcontent = g.get('content').get(b); + let acontent = cont.get(a); + let bcontent = cont.get(b); if (netNegative(acontent)) { return 1; } else if (netNegative(bcontent)) { @@ -83,7 +82,7 @@ class CommentImpl extends React.Component { static propTypes = { // html props - global: React.PropTypes.object.isRequired, + cont: React.PropTypes.object.isRequired, content: React.PropTypes.string.isRequired, sort_order: React.PropTypes.oneOf(['active', 'updated', 'new', 'trending']).isRequired, root: React.PropTypes.bool, @@ -122,8 +121,8 @@ class CommentImpl extends React.Component { } this.saveOnShow = (type) => { if(process.env.BROWSER) { - const g = this.props.global; - const content = g.get('content').get(this.props.content) + const {cont} = this.props; + const content = cont.get(this.props.content) const formId = content.get('author') + '/' + content.get('permlink') if(type) localStorage.setItem('showEditor-' + formId, JSON.stringify({type}, null, 0)) @@ -137,7 +136,7 @@ class CommentImpl extends React.Component { this.saveOnShow = this.saveOnShow.bind(this) this.onDeletePost = () => { const {props: {deletePost}} = this - const content = this.props.global.get('content').get(this.props.content); + const content = this.props.cont.get(this.props.content); deletePost(content.get('author'), content.get('permlink')) } this.toggleCollapsed = this.toggleCollapsed.bind(this); @@ -170,8 +169,7 @@ class CommentImpl extends React.Component { * it hides the comment body (but not the header) until the "reveal comment" link is clicked. */ _checkHide(props) { - const g = props.global; - const content = g.get('content').get(props.content); + const content = props.cont.get(props.content); if (content) { const hide = content.getIn(['stats', 'hide']) if(hide) { @@ -191,8 +189,8 @@ class CommentImpl extends React.Component { } initEditor(props) { if(this.state.PostReplyEditor) return - const g = props.global; - const content = g.get('content').get(props.content); + const {cont} = this.props; + const content = cont.get(props.content); if (!content) return const post = content.get('author') + '/' + content.get('permlink') const PostReplyEditor = ReplyEditor(post + '-reply') @@ -213,8 +211,8 @@ class CommentImpl extends React.Component { this.setState({PostReplyEditor, PostEditEditor}) } render() { - let g = this.props.global; - const dis = g.get('content').get(this.props.content); + const {cont} = this.props; + const dis = cont.get(this.props.content); if (!dis) { return
{translate('loading')}...
} @@ -269,11 +267,20 @@ class CommentImpl extends React.Component { if(!this.state.collapsed) { replies = comment.replies; - sortComments( g, replies, this.props.sort_order ); + sortComments( cont, replies, this.props.sort_order ); // When a comment has hidden replies and is collapsed, the reply count is off //console.log("replies:", replies.length, "num_visible:", replies.filter( reply => !g.get('content').get(reply).getIn(['stats', 'hide'])).length) - replies = replies.map((reply, idx) => ); + replies = replies.map((reply, idx) => ( + ) + ); } const commentClasses = ['hentry'] @@ -349,10 +356,10 @@ class CommentImpl extends React.Component { const Comment = connect( // mapStateToProps (state, ownProps) => { - const {global, content} = ownProps + const {content, cont} = ownProps let {depth} = ownProps if(depth == null) depth = 1 - const c = global.getIn(['content', content]) + const c = cont.get(content); let comment_link = null let rc = ownProps.rootComment if(c) { @@ -369,7 +376,7 @@ const Comment = connect( anchor_link: '#@' + content, // Using a hash here is not standard but intentional; see issue #124 for details rootComment: rc, username, - ignore, + ignore } }, diff --git a/app/components/cards/Comment.scss b/app/components/cards/Comment.scss index 0156f8fdb8..9aad097677 100644 --- a/app/components/cards/Comment.scss +++ b/app/components/cards/Comment.scss @@ -1,8 +1,14 @@ .Comment { clear: both; margin-bottom: 2.4rem; - .Markdown p { - margin: 0.1rem 0 0.6rem 0; + .Markdown { + p { + margin: 0.1rem 0 0.6rem 0; + } + + p:last-child { + margin-bottom: 0.2rem; + } } } @@ -69,7 +75,7 @@ visibility: hidden; //width: 1rem; float: right; - a { + > a { color: $dark-gray; letter-spacing: 0.1rem; padding: 0 0.5rem; @@ -90,7 +96,6 @@ .Comment__footer { margin-left: 62px; - margin-top: -0.4rem; color: $dark-gray; a { color: $dark-gray; diff --git a/app/components/cards/MarkdownViewer.jsx b/app/components/cards/MarkdownViewer.jsx index 2c6718690a..21d59c2583 100644 --- a/app/components/cards/MarkdownViewer.jsx +++ b/app/components/cards/MarkdownViewer.jsx @@ -28,11 +28,13 @@ class MarkdownViewer extends Component { jsonMetadata: React.PropTypes.object, highQualityPost: React.PropTypes.bool, noImage: React.PropTypes.bool, + allowDangerousHTML: React.PropTypes.bool, } static defaultProps = { className: '', large: false, + allowDangerousHTML: false, } constructor() { @@ -81,7 +83,9 @@ class MarkdownViewer extends Component { // Complete removal of javascript and other dangerous tags.. // The must remain as close as possible to dangerouslySetInnerHTML let cleanText = renderedText - if (this.props.className !== 'HelpContent') { + if (this.props.allowDangerousHTML === true) { + console.log('WARN\tMarkdownViewer rendering unsanitized content') + } else { cleanText = sanitize(renderedText, sanitizeConfig({large, highQualityPost, noImage: noImage && allowNoImage})) } @@ -96,19 +100,34 @@ class MarkdownViewer extends Component { // In addition to inserting the youtube compoennt, this allows react to compare separately preventing excessive re-rendering. let idx = 0 const sections = [] - // HtmlReady inserts ~~~ youtube:${id} ~~~ - for(let section of cleanText.split('~~~ youtube:')) { - if(/^[A-Za-z0-9\_\-]+ ~~~/.test(section)) { - const youTubeId = section.split(' ')[0] - section = section.substring(youTubeId.length + ' ~~~'.length) + + // HtmlReady inserts ~~~ embed:${id} type ~~~ + for(let section of cleanText.split('~~~ embed:')) { + const match = section.match(/^([A-Za-z0-9\_\-]+) (youtube|vimeo) ~~~/) + if(match && match.length >= 3) { + const id = match[1] + const type = match[2] const w = large ? 640 : 480, h = large ? 360 : 270 - sections.push( - - ) + if(type === 'youtube') { + sections.push( + + ) + } else if(type === 'vimeo') { + const url = `https://player.vimeo.com/video/${id}` + sections.push( +
+ +
+ ) + } else { + console.error('MarkdownViewer unknown embed type', type); + } + section = section.substring(`${id} ${type} ~~~`.length) + if(section === '') continue } - if(section === '') continue sections.push(
) } diff --git a/app/components/cards/PostFull.jsx b/app/components/cards/PostFull.jsx index 08c73e640d..cabaaa4740 100644 --- a/app/components/cards/PostFull.jsx +++ b/app/components/cards/PostFull.jsx @@ -21,6 +21,8 @@ import {Long} from 'bytebuffer' import {List} from 'immutable' import {repLog10, parsePayoutAmount} from 'app/utils/ParsersAndFormatters'; import DMCAList from 'app/utils/DMCAList' +import PageViewsCounter from 'app/components/elements/PageViewsCounter'; +import ShareMenu from 'app/components/elements/ShareMenu'; function TimeAuthorCategory({content, authorRepLog10, showTags}) { return ( @@ -37,7 +39,7 @@ class PostFull extends React.Component { static propTypes = { // html props /* Show extra options (component is being viewed alone) */ - global: React.PropTypes.object.isRequired, + cont: React.PropTypes.object.isRequired, post: React.PropTypes.string.isRequired, // connector props @@ -65,7 +67,7 @@ class PostFull extends React.Component { } this.onDeletePost = () => { const {props: {deletePost}} = this - const content = this.props.global.get('content').get(this.props.post); + const content = this.props.cont.get(this.props.post); deletePost(content.get('author'), content.get('permlink')) } } @@ -93,7 +95,7 @@ class PostFull extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - const names = 'global, post, username'.split(', ') + const names = 'cont, post, username'.split(', ') return names.findIndex(name => this.props[name] !== nextProps[name]) !== -1 || this.state !== nextState } @@ -129,7 +131,7 @@ class PostFull extends React.Component { } showPromotePost = () => { - const post_content = this.props.global.get('content').get(this.props.post); + const post_content = this.props.cont.get(this.props.post); if (!post_content) return const author = post_content.get('author') const permlink = post_content.get('permlink') @@ -139,7 +141,7 @@ class PostFull extends React.Component { render() { const {props: {username, post}, state: {PostFullReplyEditor, PostFullEditEditor, formId, showReply, showEdit}, onShowReply, onShowEdit, onDeletePost} = this - const post_content = this.props.global.get('content').get(this.props.post); + const post_content = this.props.cont.get(this.props.post); if (!post_content) return null; const p = extractContent(immutableAccessor, post_content); const content = post_content.toJS(); @@ -202,8 +204,12 @@ class PostFull extends React.Component { const pending_payout = parsePayoutAmount(content.pending_payout_value); const total_payout = parsePayoutAmount(content.total_payout_value); const high_quality_post = pending_payout + total_payout > 10.0; + const full_power = post_content.get('percent_steem_dollars') === 0; - let post_header =

{content.title}

+ let post_header =

+ {content.title} + {full_power && } +

if(content.depth > 0) { let parent_link = `/${content.category}/@${content.parent_author}/${content.parent_permlink}`; let direct_parent_link @@ -263,18 +269,21 @@ class PostFull extends React.Component {
{!readonly && } - - - {content.children} - - {!readonly && {showReplyOption && Reply} {' '}{showEditOption && !showEdit && Edit} {' '}{showDeleteOption && !showReply && Delete} } - + + + {content.children} + + + + +
diff --git a/app/components/cards/PostFull.scss b/app/components/cards/PostFull.scss index 4ccbf22024..e886823d60 100644 --- a/app/components/cards/PostFull.scss +++ b/app/components/cards/PostFull.scss @@ -24,6 +24,10 @@ > h1 { overflow: hidden; font: 700 200% "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Arial, sans-serif; + .Icon { + margin: 0 0 0 0.5rem; + vertical-align: -30%; + } } a { color: $dark-gray; @@ -34,7 +38,7 @@ border-right: none; .Icon.clock { top: 5px; - path { + svg { fill: $dark-gray; } } @@ -50,7 +54,7 @@ clear: right; line-height: 2rem; font-size: 94%; - svg path, svg polygon { + svg { fill: $dark-gray; } a, .FoundationDropdownMenu__label { @@ -81,8 +85,17 @@ } .PostFull__responses { + padding-right: 1rem; + //margin-right: 1rem; + //border-right: 1px solid $medium-gray; +} + +.PostFull__views { padding-right: 1rem; margin-right: 1rem; + color: $dark-gray; + font-size: 94%; + font-weight: 600; border-right: 1px solid $medium-gray; } diff --git a/app/components/cards/PostSummary.jsx b/app/components/cards/PostSummary.jsx index 66a13abc98..fe6f061daa 100644 --- a/app/components/cards/PostSummary.jsx +++ b/app/components/cards/PostSummary.jsx @@ -79,6 +79,7 @@ class PostSummary extends React.Component { let title_text = p.title; let comments_link; let is_comment = false; + let full_power = content.get('percent_steem_dollars') === 0; if( content.get( 'parent_author') !== "" ) { title_text = "Re: " + content.get('root_title'); @@ -94,7 +95,10 @@ class PostSummary extends React.Component { navigate(e, onClick, post, title_link_url)}>{desc}
; let content_title =

- navigate(e, onClick, post, title_link_url)}>{title_text} + navigate(e, onClick, post, title_link_url)}> + {title_text} + {full_power && } +

; // author and category diff --git a/app/components/cards/PostSummary.scss b/app/components/cards/PostSummary.scss index ad515049ae..e4a0edbdf0 100644 --- a/app/components/cards/PostSummary.scss +++ b/app/components/cards/PostSummary.scss @@ -52,6 +52,14 @@ ul.PostsList__summaries { > a:visited { color: #777; } + .Icon { + margin: 0 0.25rem; + svg { + width: 0.85rem; + height: 0.85rem; + vertical-align: 5%; + } + } } } .PostSummary__collapse { diff --git a/app/components/cards/PostsList.jsx b/app/components/cards/PostsList.jsx index 067fbb77d4..f47a956a45 100644 --- a/app/components/cards/PostsList.jsx +++ b/app/components/cards/PostsList.jsx @@ -2,12 +2,12 @@ import React, {PropTypes} from 'react'; import PostSummary from 'app/components/cards/PostSummary'; import Post from 'app/components/pages/Post'; import LoadingIndicator from 'app/components/elements/LoadingIndicator'; -import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; import debounce from 'lodash.debounce'; -import Callout from 'app/components/elements/Callout'; import CloseButton from 'react-foundation-components/lib/global/close-button'; import {findParent} from 'app/utils/DomUtils'; import Icon from 'app/components/elements/Icon'; +import {List} from "immutable"; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; function topPosition(domElt) { if (!domElt) { @@ -19,14 +19,10 @@ function topPosition(domElt) { class PostsList extends React.Component { static propTypes = { - posts: PropTypes.array.isRequired, + posts: PropTypes.object.isRequired, loading: PropTypes.bool.isRequired, category: PropTypes.string, loadMore: PropTypes.func, - emptyText: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.node, - ]), showSpam: PropTypes.bool, fetchState: PropTypes.func.isRequired, pathname: PropTypes.string, @@ -54,16 +50,7 @@ class PostsList extends React.Component { this.attachScrollListener(); } - componentWillUnmount() { - this.detachScrollListener(); - window.removeEventListener('popstate', this.onBackButton); - window.removeEventListener('keydown', this.onBackButton); - const post_overlay = document.getElementById('post_overlay'); - if (post_overlay) post_overlay.removeEventListener('click', this.closeOnOutsideClick); - document.getElementsByTagName('body')[0].className = ""; - } - - componentWillUpdate(nextProps) { + componentWillUpdate() { const location = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (this.state.showPost && (location !== this.post_url)) { this.setState({showPost: null}); @@ -88,8 +75,17 @@ class PostsList extends React.Component { } } + componentWillUnmount() { + this.detachScrollListener(); + window.removeEventListener('popstate', this.onBackButton); + window.removeEventListener('keydown', this.onBackButton); + const post_overlay = document.getElementById('post_overlay'); + if (post_overlay) post_overlay.removeEventListener('click', this.closeOnOutsideClick); + document.getElementsByTagName('body')[0].className = ""; + } + onBackButton(e) { - if (e.keyCode && e.keyCode !== 27) return; + if ('keyCode' in e && e.keyCode !== 27) return; window.removeEventListener('popstate', this.onBackButton); window.removeEventListener('keydown', this.onBackButton); this.setState({showPost: null}); @@ -102,11 +98,16 @@ class PostsList extends React.Component { if (!inside_top_bar) { const post_overlay = document.getElementById('post_overlay'); if (post_overlay) post_overlay.removeEventListener('click', this.closeOnOutsideClick); - this.setState({showPost: null}); + this.closePostModal(); } } } + closePostModal = () => { + window.document.title = this.state.prevTitle; + this.setState({showPost: null, prevTitle: null}); + } + fetchIfNeeded() { this.scrollListener(); } @@ -124,11 +125,11 @@ class PostsList extends React.Component { (document.documentElement || document.body.parentNode || document.body).scrollTop; if (topPosition(el) + el.offsetHeight - scrollTop - window.innerHeight < 10) { const {loadMore, posts, category} = this.props; - if (loadMore && posts && posts.length > 0) loadMore(posts[posts.length - 1], category); + if (loadMore && posts && posts.size) loadMore(posts.last(), category); } // Detect if we're in mobile mode (renders larger preview imgs) - var mq = window.matchMedia('screen and (max-width: 39.9375em)'); + const mq = window.matchMedia('screen and (max-width: 39.9375em)'); if(mq.matches) { this.setState({thumbSize: 'mobile'}) } else { @@ -150,26 +151,45 @@ class PostsList extends React.Component { onPostClick(post, url) { this.post_url = url; this.props.fetchState(url); - this.setState({showPost: post}); + this.setState({showPost: post, prevTitle: window.document.title}); window.history.pushState({}, '', url); } render() { - const {posts, loading, category, emptyText} = this.props; - const {comments} = this.props - const {account} = this.props + const {posts, showSpam, loading, category, content, + follow, account} = this.props; const {thumbSize, showPost} = this.state - if (!loading && !posts.length && emptyText) { - return {emptyText}; - } - const renderSummary = items => items.map(({item, ignore, netVoteSign, authorRepLog10}) =>
  • - + const postsInfo = []; + posts.forEach(item => { + const cont = content.get(item); + if(!cont) { + console.error('PostsList --> Missing cont key', item) + return + } + const key = [cont.get('author')] + const ignore = follow ? follow.getIn(key, List()).contains('ignore') : false + const {hide, netVoteSign, authorRepLog10} = cont.get('stats').toJS() + if(!(ignore || hide) || showSpam) // rephide + postsInfo.push({item, ignore, netVoteSign, authorRepLog10}) + }); + + const renderSummary = items => items.map(item =>
  • +
  • ) + return (
      - {renderSummary(comments)} + {renderSummary(postsInfo)}
    {loading &&
    } {showPost &&
    @@ -178,7 +198,7 @@ class PostsList extends React.Component { - {this.setState({showPost: null})}} /> +
    @@ -190,30 +210,17 @@ class PostsList extends React.Component { } } -import {List} from 'immutable' +// import {List, Map} from 'immutable' import {connect} from 'react-redux' export default connect( (state, props) => { - const {posts, showSpam} = props; - const comments = [] const pathname = state.app.get('location').pathname; - posts.forEach(item => { - const content = state.global.get('content').get(item); - if(!content) { - console.error('PostsList --> Missing content key', item) - return - } - // let total_payout = 0; - const current = state.user.get('current') - const username = current ? current.get('username') : null - const key = ['follow', 'get_following', username, 'result', content.get('author')] - const ignore = username ? state.global.getIn(key, List()).contains('ignore') : false - const {hide, netVoteSign, authorRepLog10} = content.get('stats').toJS() - if(!(ignore || hide) || showSpam) // rephide - comments.push({item, ignore, netVoteSign, authorRepLog10}) - }) - return {...props, comments, pathname}; + const current = state.user.get('current') + const username = current ? current.get('username') : null + const content = state.global.get('content'); + const follow = state.global.getIn(['follow', 'get_following', username, 'result']); + return {...props, username, content, follow, pathname}; }, dispatch => ({ fetchState: (pathname) => { diff --git a/app/components/cards/PostsList.scss b/app/components/cards/PostsList.scss index b8c713f723..f918224614 100644 --- a/app/components/cards/PostsList.scss +++ b/app/components/cards/PostsList.scss @@ -5,13 +5,13 @@ width: 100%; height: 100%; z-index: 300; - overflow-x: hidden; - overflow-y: scroll; + background-color: $white; // padding: 0 .9rem; - -webkit-overflow-scrolling: touch; } + + .PostsList__post_top_overlay { position: fixed; top: 0; @@ -19,8 +19,7 @@ width: 100%; z-index: 310; height: 2.5rem; - overflow-x: hidden; - overflow-y: scroll; + overflow: hidden; border-bottom: 1px solid $light-gray; } @@ -48,10 +47,11 @@ } .PostsList__post_container { - position: relative; - background-color: $white; - margin: 1rem auto; - padding: 2rem 0.9rem 0 0.9rem; + overflow: hidden; + position: relative; + background-color: $white; + margin: 1rem auto; + padding: 2rem 0.9rem 0 0.9rem; .PostFull { background-color: $white; } @@ -85,7 +85,21 @@ body.with-post-overlay { } } +@media screen and (max-width: 66rem) { + .PostsList__post_container { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + height: 100%; + } +} + @media screen and (min-width: 67rem) { + + .PostsList__post_overlay { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + .PostsList__post_container { width: 62rem; } diff --git a/app/components/elements/Author.jsx b/app/components/elements/Author.jsx index 189bfb5f64..01b80c721e 100644 --- a/app/components/elements/Author.jsx +++ b/app/components/elements/Author.jsx @@ -7,6 +7,9 @@ import Icon from 'app/components/elements/Icon'; import { Link } from 'react-router'; import {authorNameAndRep} from 'app/utils/ComponentFormatters'; import Reputation from 'app/components/elements/Reputation'; +import Userpic from 'app/components/elements/Userpic'; +import { translate } from 'app/Translator'; +import normalizeProfile from 'app/utils/NormalizeProfile'; const {string, bool, number} = React.PropTypes @@ -27,24 +30,43 @@ class Author extends React.Component { const {username} = this.props // redux const author_link = - if(!username || !(follow || mute)) + if(!username || !(follow || mute) || username === author) return author_link + const {name, about} = this.props.account ? normalizeProfile(this.props.account.toJS()) : {} + const dropdown =
    - Profile   - + + + + + + {name} + + + @{author} + +
    + +
    + +
    + {about} +
    + + return ( @@ -65,9 +87,11 @@ export default connect( (state, ownProps) => { const current = state.user.get('current') const username = current && current.get('username') + const account = state.global.getIn(['accounts', ownProps.author]); return { ...ownProps, username, + account, } }, // dispatch => ({ diff --git a/app/components/elements/Author.scss b/app/components/elements/Author.scss new file mode 100644 index 0000000000..f4fcbe0eac --- /dev/null +++ b/app/components/elements/Author.scss @@ -0,0 +1,28 @@ +.Author__dropdown { + width: 290px; + min-height: 108px; + + .Userpic { + margin-right: 1rem; + float: left; + } + + .Author__name { + text-decoration: none; + display: block; + font-size: 110%; + color: #444; + font-weight: 600; + line-height: 1; + } + + .Author__username { + text-decoration: none; + font-size: 90%; + color: #666; + } + + .Author__bio { + font-size: 90%; + } +} diff --git a/app/components/elements/ChangePassword.jsx b/app/components/elements/ChangePassword.jsx index abf96353ba..c74f738fd3 100644 --- a/app/components/elements/ChangePassword.jsx +++ b/app/components/elements/ChangePassword.jsx @@ -210,7 +210,7 @@ const keyValidate = (values) => ({ confirmSaved: ! values.confirmSaved ? translate('required') : null, }) -import {reduxForm} from 'redux-form' +import {reduxForm} from 'redux-form' // @deprecated, instead use: app/utils/ReactForm.js export default reduxForm( { form: 'changePassword', fields: ['password', 'confirmPassword', 'confirmCheck', 'confirmSaved', 'twofa'] }, // mapStateToProps diff --git a/app/components/elements/ConvertToSteem.jsx b/app/components/elements/ConvertToSteem.jsx index bdb63695b8..d63e418453 100644 --- a/app/components/elements/ConvertToSteem.jsx +++ b/app/components/elements/ConvertToSteem.jsx @@ -1,7 +1,7 @@ /* eslint react/prop-types: 0 */ import React from 'react' import ReactDOM from 'react-dom'; -import {reduxForm} from 'redux-form'; +import {reduxForm} from 'redux-form'; // @deprecated, instead use: app/utils/ReactForm.js import transaction from 'app/redux/Transaction' import shouldComponentUpdate from 'app/utils/shouldComponentUpdate' import TransactionError from 'app/components/elements/TransactionError' diff --git a/app/components/elements/DateJoinWrapper.jsx b/app/components/elements/DateJoinWrapper.jsx index cc84e15496..a7179a9319 100644 --- a/app/components/elements/DateJoinWrapper.jsx +++ b/app/components/elements/DateJoinWrapper.jsx @@ -9,7 +9,7 @@ export default class DateJoinWrapper extends React.Component { let joinMonth = monthNames[date.getMonth()]; let joinYear = date.getFullYear(); return ( -

    Joined {joinMonth}, {joinYear}

    + Joined {joinMonth} {joinYear} ) } } diff --git a/app/components/elements/DepthChart.jsx b/app/components/elements/DepthChart.jsx index 69c2c5a65b..a9e2f67e68 100644 --- a/app/components/elements/DepthChart.jsx +++ b/app/components/elements/DepthChart.jsx @@ -6,6 +6,7 @@ const ReactHighcharts = require("react-highcharts/dist/ReactHighstock"); // multiply the x values by a constant factor and divide by this factor for // display purposes (tooltip, x-axis) const power = 100; +const precision = 1000; function orderEqual(a, b) { return ( @@ -161,7 +162,7 @@ function generateDepthChart(bidsArray, asksArray) { labels: { align: "left", formatter: function () { - let value = this.value / power; + let value = this.value / precision; return "$" + (value > 10e6 ? (value / 10e6).toFixed(2) + "M" : value > 10000 ? (value / 10e3).toFixed(2) + "k" : value); diff --git a/app/components/elements/DropdownMenu.jsx b/app/components/elements/DropdownMenu.jsx index 34027f28c9..1a0c017703 100644 --- a/app/components/elements/DropdownMenu.jsx +++ b/app/components/elements/DropdownMenu.jsx @@ -24,7 +24,7 @@ export default class DropdownMenu extends React.Component { } componentWillUnmount() { - document.removeEventListener('mousedown', this.hide); + document.removeEventListener('click', this.hide); } toggle = (e) => { @@ -36,7 +36,7 @@ export default class DropdownMenu extends React.Component { show = (e) => { e.preventDefault(); this.setState({shown: true}); - document.addEventListener('mousedown', this.hide); + document.addEventListener('click', this.hide); }; hide = (e) => { @@ -44,8 +44,9 @@ export default class DropdownMenu extends React.Component { const inside_dropdown = !!findParent(e.target, 'VerticalMenu'); if (inside_dropdown) return; + e.preventDefault() this.setState({shown: false}); - document.removeEventListener('mousedown', this.hide); + document.removeEventListener('click', this.hide); }; navigate = (e) => { @@ -71,7 +72,7 @@ export default class DropdownMenu extends React.Component { {hasDropdown && }
    - if(hasDropdown) entry = {e.preventDefault()}}>{entry} + if(hasDropdown) entry = {entry} const menu = ; const cls = 'DropdownMenu' + (this.state.shown ? ' show' : '') + (className ? ` ${className}` : '') diff --git a/app/components/elements/HelpContent.jsx b/app/components/elements/HelpContent.jsx index b2d6728b5e..21cd0db33a 100644 --- a/app/components/elements/HelpContent.jsx +++ b/app/components/elements/HelpContent.jsx @@ -110,6 +110,6 @@ export default class HelpContent extends React.Component { value = value.replace(//gi, (match, name) => { return renderToString(); }); - return ; + return ; } } diff --git a/app/components/elements/Icon.jsx b/app/components/elements/Icon.jsx index 4978a89837..1b16853019 100644 --- a/app/components/elements/Icon.jsx +++ b/app/components/elements/Icon.jsx @@ -32,6 +32,9 @@ const icons = [ 'photo', 'line', 'video', + 'eye', + 'location', + 'calendar', ]; const icons_map = {}; for (const i of icons) icons_map[i] = require(`app/assets/icons/${i}.svg`); diff --git a/app/components/elements/KeyEdit.js b/app/components/elements/KeyEdit.js index d5a662bd21..95ac06511c 100644 --- a/app/components/elements/KeyEdit.js +++ b/app/components/elements/KeyEdit.js @@ -1,6 +1,6 @@ import React, {PropTypes, Component} from 'react' import LoadingIndicator from 'app/components/elements/LoadingIndicator' -import {reduxForm} from 'redux-form' +import {reduxForm} from 'redux-form' // @deprecated, instead use: app/utils/ReactForm.js import {PrivateKey} from 'shared/ecc' import {cleanReduxInput} from 'app/utils/ReduxForms' import { translate } from 'app/Translator'; diff --git a/app/components/elements/PageViewsCounter.jsx b/app/components/elements/PageViewsCounter.jsx new file mode 100644 index 0000000000..ff3dbbd9be --- /dev/null +++ b/app/components/elements/PageViewsCounter.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {recordPageView} from 'app/utils/ServerApiClient'; +import Icon from 'app/components/elements/Icon'; +import pluralize from 'pluralize'; + +export default class PageViewsCounter extends React.Component { + + static propTypes = { + hidden: React.PropTypes.bool + }; + + static defaultProps = { + hidden: true + }; + + constructor(props) { + super(props); + this.state = {views: 0}; + this.last_page = null; + } + + pageView() { + let ref = document.referrer || ''; + if (ref.match('://' + window.location.hostname)) ref = ''; + recordPageView(window.location.pathname, ref).then(views => this.setState({views})); + this.last_page = window.location.pathname; + } + + componentDidMount() { + this.pageView(); + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.views !== this.state.views || window.location.pathname !== this.last_page; + } + + componentDidUpdate() { + this.pageView(); + } + + render() { + const views = this.state.views; + if (this.props.hidden || !views) return null; + return + {views} + ; + } +} diff --git a/app/components/elements/ReplyEditor.jsx b/app/components/elements/ReplyEditor.jsx index 35ede76298..5850f01e71 100644 --- a/app/components/elements/ReplyEditor.jsx +++ b/app/components/elements/ReplyEditor.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import {reduxForm} from 'redux-form' +import {reduxForm} from 'redux-form' // @deprecated, instead use: app/utils/ReactForm.js import transaction from 'app/redux/Transaction'; import MarkdownViewer from 'app/components/cards/MarkdownViewer' import CategorySelector from 'app/components/cards/CategorySelector' @@ -390,7 +390,7 @@ export default formId => reduxForm( const fields = ['body', 'autoVote'] const {type, parent_author, jsonMetadata} = ownProps const isStory = /submit_story/.test(type) || ( - /edit/.test(type) && parent_author === '' + type === 'edit' && parent_author === '' ) if (isStory) fields.push('title') if (isStory) fields.push('category') @@ -437,17 +437,20 @@ export default formId => reduxForm( // const post = state.global.getIn(['content', author + '/' + permlink]) const username = state.user.getIn(['current', 'username']) + const isEdit = type === 'edit' + const isNew = /^submit_/.test(type) + // Wire up the current and parent props for either an Edit or a Submit (new post) //'submit_story', 'submit_comment', 'edit' const linkProps = - /^submit_/.test(type) ? { // submit new + isNew ? { // submit new parent_author: author, parent_permlink: permlink, author: username, // permlink, assigned in TransactionSaga } : // edit existing - /^edit$/.test(type) ? {author, permlink, parent_author, parent_permlink} + isEdit ? {author, permlink, parent_author, parent_permlink} : null if (!linkProps) throw new Error('Unknown type: ' + type) @@ -480,7 +483,7 @@ export default formId => reduxForm( if(rootTag) allCategories = allCategories.add(rootTag) // merge - const meta = /edit/.test(type) ? jsonMetadata : {} + const meta = isEdit ? jsonMetadata : {} if(allCategories.size) meta.tags = allCategories.toJS(); else delete meta.tags if(rtags.usertags.size) meta.users = rtags.usertags; else delete meta.users if(rtags.images.size) meta.image = rtags.images; else delete meta.image @@ -500,28 +503,31 @@ export default formId => reduxForm( } if(meta.tags.length > 5) { - const includingCategory = /edit/.test(type) ? ` (including the category '${rootCategory}')` : '' + const includingCategory = isEdit ? ` (including the category '${rootCategory}')` : '' errorCallback(`You have ${meta.tags.length} tags total${includingCategory}. Please use only 5 in your post and category line.`) return } // loadingCallback starts the loading indicator loadingCallback() - const originalBody = /edit/.test(type) ? originalPost.body : null + const originalBody = isEdit ? originalPost.body : null const __config = {originalBody, autoVote} - switch(payoutType) { - case '0%': // decline payout - __config.comment_options = { - max_accepted_payout: '0.000 SBD', - } - break; - case '100%': // 100% steem power payout - __config.comment_options = { - percent_steem_dollars: 0, // 10000 === 100% (of 50%) - } - break; - default: // 50% steem power, 50% sd+steem + // Avoid changing payout option during edits #735 + if(!isEdit) { + switch(payoutType) { + case '0%': // decline payout + __config.comment_options = { + max_accepted_payout: '0.000 SBD', + } + break; + case '100%': // 100% steem power payout + __config.comment_options = { + percent_steem_dollars: 0, // 10000 === 100% (of 50%) + } + break; + default: // 50% steem power, 50% sd+steem + } } const operation = { diff --git a/app/components/elements/ShareMenu.jsx b/app/components/elements/ShareMenu.jsx new file mode 100644 index 0000000000..6375b98fe6 --- /dev/null +++ b/app/components/elements/ShareMenu.jsx @@ -0,0 +1,33 @@ +import React from 'react' +import { Link } from 'react-router' +import Icon from 'app/components/elements/Icon.jsx'; + +export default class ShareMenu extends React.Component { + + static propTypes = { + menu: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + title: React.PropTypes.string + }; + + render(){ + const title = this.props.title; + const items = this.props.menu; + return +
      + {title &&
    • {title}
    • } + {items.map(i => { + return
    • + {i.link ? + {i.icon && }{} +   {i.addon} + : + + {i.icon && }{i.label ? i.label : i.value} + + } +
    • + })} +
    +
    + } +} diff --git a/app/components/elements/ShareMenu.scss b/app/components/elements/ShareMenu.scss new file mode 100644 index 0000000000..31ba48f7eb --- /dev/null +++ b/app/components/elements/ShareMenu.scss @@ -0,0 +1,21 @@ +.shareMenu { + display: inline-block; + vertical-align: middle; +} + +.shareItems { + list-style: none; + display: inline; + margin-left: 0.01em; + li { + float: left; + padding-left: 5px; + } + li > a:hover { + color: #ffffff; + svg {fill: #1A5099;} + } + li > a:link { + text-decoration: none; + } +} diff --git a/app/components/elements/Userpic.jsx b/app/components/elements/Userpic.jsx index fec64da05b..feb9185bc4 100644 --- a/app/components/elements/Userpic.jsx +++ b/app/components/elements/Userpic.jsx @@ -20,7 +20,8 @@ class Userpic extends Component { } catch (e) {} if (url && /^(https?:)\/\//.test(url)) { - url = $STM_Config.img_proxy_prefix + '48x48/' + url; + const size = width && width > 48 ? '320x320' : '72x72' + url = $STM_Config.img_proxy_prefix + size + '/' + url; } else { if(hideIfDefault) { return null; diff --git a/app/components/elements/VerticalMenu.jsx b/app/components/elements/VerticalMenu.jsx index 4492e647f0..992621ecdd 100644 --- a/app/components/elements/VerticalMenu.jsx +++ b/app/components/elements/VerticalMenu.jsx @@ -13,7 +13,11 @@ export default class VerticalMenu extends React.Component { ]), }; - closeMenu = () => { + closeMenu = (e) => { + // If this was not a left click, or if CTRL or CMD were held, do not close the menu. + if(e.button !== 0 || e.ctrlKey || e.metaKey) return; + + // Simulate clicking of document body which will close any open menus document.body.click(); } diff --git a/app/components/elements/Voting.jsx b/app/components/elements/Voting.jsx index 533ba2a718..37720b6fc5 100644 --- a/app/components/elements/Voting.jsx +++ b/app/components/elements/Voting.jsx @@ -111,7 +111,7 @@ class Voting extends React.Component { } render() { - const {myVote, active_votes, showList, voting, flag, vesting_shares} = this.props; + const {myVote, active_votes, showList, voting, flag, vesting_shares, is_comment} = this.props; const {username} = this.props; const {votingUp, votingDown, showWeight, weight} = this.state; // console.log('-- Voting.render -->', myVote, votingUp, votingDown); @@ -161,7 +161,9 @@ class Voting extends React.Component { const up = ; const classUp = 'Voting__button Voting__button-up' + (myVote > 0 ? ' Voting__button--upvoted' : '') + (votingUpActive ? ' votingUp' : ''); - const cashout_active = pending_payout > 0 || (cashout_time && cashout_time.indexOf('1969') !== 0 && cashout_time.indexOf('1970') !== 0) + // TODO: clean up the date logic after shared-db upgrade + // There is an "active cashout" if: (a) there is a pending payout, OR (b) there is a valid cashout_time AND (it's a top level post OR a comment with at least 1 vote) + const cashout_active = pending_payout > 0 || (cashout_time && cashout_time.indexOf('1969') !== 0 && cashout_time.indexOf('1970') !== 0 && (active_votes.size > 0 || !is_comment)) const payoutItems = []; if(cashout_active) { @@ -170,7 +172,7 @@ class Voting extends React.Component { if(promoted > 0) { payoutItems.push({value: 'Promotion Cost $' + formatDecimal(promoted).join('')}); } - const hide_cashout_532 = cashout_time.indexOf('1969') === 0 // tmpfix for #532 + const hide_cashout_532 = cashout_time.indexOf('1969') === 0 // tmpfix for #532. TODO: remove after shared-db if (cashout_active && !hide_cashout_532) { payoutItems.push({value: }); } diff --git a/app/components/elements/YoutubePreview.jsx b/app/components/elements/YoutubePreview.jsx index b51d3ec367..6de7220f91 100644 --- a/app/components/elements/YoutubePreview.jsx +++ b/app/components/elements/YoutubePreview.jsx @@ -37,7 +37,7 @@ export default class YoutubePreview extends React.Component { // mqdefault.jpg (medium quality version, 320px × 180px) // hqdefault.jpg (high quality version, 480px × 360px // sddefault.jpg (standard definition version, 640px × 480px) - const thumbnail = width <= 320 ? 'mqdefault.jpg' : width <= 480 ? 'hqdefault.jpg' : '0.jpg' + const thumbnail = width <= 320 ? 'mqdefault.jpg' : width <= 480 ? 'hqdefault.jpg' : 'maxresdefault.jpg' const previewLink = `https://img.youtube.com/vi/${youTubeId}/${thumbnail}` return (
    diff --git a/app/components/modules/BlocktradesDeposit.jsx b/app/components/modules/BlocktradesDeposit.jsx index 877f02d7a3..071bfd4a22 100644 --- a/app/components/modules/BlocktradesDeposit.jsx +++ b/app/components/modules/BlocktradesDeposit.jsx @@ -1,6 +1,6 @@ import React from 'react'; import {Map} from 'immutable' -import {reduxForm} from 'redux-form' +import {reduxForm} from 'redux-form' // @deprecated, instead use: app/utils/ReactForm.js import TimeAgoWrapper from 'app/components/elements/TimeAgoWrapper' import Icon from 'app/components/elements/Icon' import DropdownMenu from 'app/components/elements/DropdownMenu' diff --git a/app/components/modules/ConfirmTransactionForm.jsx b/app/components/modules/ConfirmTransactionForm.jsx index 5415b8aca6..4f06c603e8 100644 --- a/app/components/modules/ConfirmTransactionForm.jsx +++ b/app/components/modules/ConfirmTransactionForm.jsx @@ -7,7 +7,7 @@ class ConfirmTransactionForm extends Component { static propTypes = { //Steemit onCancel: PropTypes.func, - + warning: PropTypes.string, // redux-form confirm: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), confirmBroadcastOperation: PropTypes.object, @@ -25,13 +25,14 @@ class ConfirmTransactionForm extends Component { } render() { const {onCancel, okClick} = this - const {confirm, confirmBroadcastOperation} = this.props + const {confirm, confirmBroadcastOperation, warning} = this.props const conf = typeof confirm === 'function' ? confirm() : confirm return (

    {typeName(confirmBroadcastOperation)}


    {conf}
    + {warning ?
    {warning}
    : null}
    @@ -52,10 +53,12 @@ export default connect( const confirmBroadcastOperation = state.transaction.get('confirmBroadcastOperation') const confirmErrorCallback = state.transaction.get('confirmErrorCallback') const confirm = state.transaction.get('confirm') + const warning = state.transaction.get('warning') return { confirmBroadcastOperation, confirmErrorCallback, confirm, + warning } }, // mapDispatchToProps diff --git a/app/components/modules/Header.scss b/app/components/modules/Header.scss index 539a30b67b..82c3dc4912 100644 --- a/app/components/modules/Header.scss +++ b/app/components/modules/Header.scss @@ -51,6 +51,11 @@ display: flex; text-transform: lowercase; } + @media screen and (max-width: 39.9375em) { + .shrink { + padding: .3rem 1rem; + } + } } ul > li.Header__top-logo > a { diff --git a/app/components/modules/LoginForm.jsx b/app/components/modules/LoginForm.jsx index 2bb7b8fe83..5b055f416f 100644 --- a/app/components/modules/LoginForm.jsx +++ b/app/components/modules/LoginForm.jsx @@ -155,13 +155,12 @@ class LoginForm extends Component {
    ; } } - const standardPassword = checkPasswordChecksum(password.value) - let password_info = null - if (standardPassword !== undefined && !standardPassword) - password_info = 'This password was probably typed or copied incorrectly. A password generated by Steemit should not contain 0 (zero), O (capital o), I (capital i) and l (lower case L) characters.' + const password_info = checkPasswordChecksum(password.value) === false ? + 'This password or private key was entered incorrectly. There is probably a handwriting or data-entry error. Hint: A password or private key generated by Steemit will never contain 0 (zero), O (capital o), I (capital i) and l (lower case L) characters.' : + null const form = ( -
    { + { // bind redux-form to react-redux console.log('Login\tdispatchSubmit'); return dispatchSubmit(data, loginBroadcastOperation, afterLoginRedirectToWelcome) @@ -178,7 +177,7 @@ class LoginForm extends Component {
    {error &&
    {error} 
    } - {password_info &&
    {password_info} 
    } + {error && password_info &&
    {password_info} 
    }
    {loginBroadcastOperation &&
    This operation requires your {authType} key (or use your master password).
    @@ -226,11 +225,15 @@ function urlAccountName() { } function checkPasswordChecksum(password) { - if(!/^P.{45,}/.test(password)) {// 52 is the length + // A Steemit generated password is a WIF prefixed with a P .. + // It is possible to login directly with a WIF + const wif = /^P/.test(password) ? password.substring(1) : password + + if(!/^5[HJK].{45,}/i.test(wif)) {// 51 is the wif length // not even close return undefined } - const wif = password.substring(1) + return PrivateKey.isWif(wif) } diff --git a/app/components/modules/Settings.jsx b/app/components/modules/Settings.jsx index 8bab7be48c..10fdc830de 100644 --- a/app/components/modules/Settings.jsx +++ b/app/components/modules/Settings.jsx @@ -6,44 +6,71 @@ import {ALLOWED_CURRENCIES} from 'config/client_config' import store from 'store'; import transaction from 'app/redux/Transaction' import o2j from 'shared/clash/object2json' +import LoadingIndicator from 'app/components/elements/LoadingIndicator' import Userpic from 'app/components/elements/Userpic'; +import reactForm from 'app/utils/ReactForm' class Settings extends React.Component { - state = { - errorMessage: '', - succesMessage: '', - userImage: this.props.userImage || '', - changed: false + constructor(props) { + super() + this.initForm(props) } - handleCurrencyChange(event) { store.set('currency', event.target.value) } - - handleLanguageChange = (event) => { - const language = event.target.value - store.set('language', language) - this.props.changeLanguage(language) + state = { + errorMessage: '', + successMessage: '', } - handleUrlChange = event => { - this.setState({userImage: event.target.value, changed: true}) + initForm(props) { + reactForm({ + instance: this, + name: 'accountSettings', + fields: ['profile_image', 'name', 'about', 'location', 'website'], + initialValues: props.profile, + validation: values => ({ + profile_image: values.profile_image && !/^https?:\/\//.test(values.profile_image) ? 'Invalid URL' : null, + name: values.name && values.name.length > 20 ? 'Name is too long' : null, + about: values.about && values.about.length > 160 ? 'About is too long' : null, + location: values.location && values.location.length > 30 ? 'Location is too long' : null, + website: values.website && values.website.length > 100 ? 'Website URL is too long' : null, + }) + }) + this.handleSubmitForm = + this.state.accountSettings.handleSubmit(args => this.handleSubmit(args)) } - handleUserImageSubmit = event => { - event.preventDefault() - this.setState({loading: true}) - - const {account, updateAccount} = this.props + handleSubmit = ({updateInitialValues}) => { let {metaData} = this.props - if (!metaData) metaData = {} - if (metaData == '{created_at: \'GENESIS\'}') metaData = {created_at: "GENESIS"} if(!metaData.profile) metaData.profile = {} - metaData.profile.profile_image = this.state.userImage - metaData = JSON.stringify(metaData); + delete metaData.user_image; // old field... cleanup + + const {profile_image, name, about, location, website} = this.state + + // Update relevant fields + metaData.profile.profile_image = profile_image.value + metaData.profile.name = name.value + metaData.profile.about = about.value + metaData.profile.location = location.value + metaData.profile.website = website.value + + // Remove empty keys + if(!metaData.profile.profile_image) delete metaData.profile.profile_image; + if(!metaData.profile.name) delete metaData.profile.name; + if(!metaData.profile.about) delete metaData.profile.about; + if(!metaData.profile.location) delete metaData.profile.location; + if(!metaData.profile.website) delete metaData.profile.website; + // TODO: Update language & currency + //store.set('language', language) + //this.props.changeLanguage(language) + //store.set('currency', event.target.value) + + const {account, updateAccount} = this.props + this.setState({loading: true}) updateAccount({ - json_metadata: metaData, + json_metadata: JSON.stringify(metaData), account: account.name, memo_key: account.memo_key, errorCallback: (e) => { @@ -66,17 +93,25 @@ class Settings extends React.Component { loading: false, changed: false, errorMessage: '', - succesMessage: translate('saved') + '!', + successMessage: translate('saved') + '!', }) - // remove succesMessage after a while - setTimeout(() => this.setState({succesMessage: ''}), 2000) + // remove successMessage after a while + setTimeout(() => this.setState({successMessage: ''}), 4000) + updateInitialValues() } }) } render() { const {state, props} = this + + const {submitting, valid, touched} = this.state.accountSettings + const disabled = !props.isOwnAccount || state.loading || submitting || !valid || !touched + + const {profile_image, name, about, location, website} = this.state + return
    + {/*
    */}
    - - - -
    -
    @@ -129,18 +191,18 @@ class Settings extends React.Component { export default connect( // mapStateToProps (state, ownProps) => { - const {accountname} = ownProps.routeParams + const {accountname} = ownProps.routeParams const account = state.global.getIn(['accounts', accountname]).toJS() const current_user = state.user.get('current') const username = current_user ? current_user.get('username') : '' const metaData = account ? o2j.ifStringParseJSON(account.json_metadata) : {} - const userImage = metaData && metaData.profile ? metaData.profile.profile_image : '' + const profile = metaData && metaData.profile ? metaData.profile : {} return { account, metaData, - userImage, isOwnAccount: username == accountname, + profile, ...ownProps } }, diff --git a/app/components/modules/Transfer.jsx b/app/components/modules/Transfer.jsx index fc8a8ee33d..5923eff85e 100644 --- a/app/components/modules/Transfer.jsx +++ b/app/components/modules/Transfer.jsx @@ -126,8 +126,7 @@ class TransferForm extends Component { const {submitting, valid, handleSubmit} = this.state.transfer const isMemoPrivate = memo && /^#/.test(memo.value) const form = ( -
    { - // bind redux-form to react-redux + { this.setState({loading: true}) dispatchSubmit({...data, errorCallback: this.errorCallback, currentUser, toVesting, transferType}) })} diff --git a/app/components/modules/UserWallet.jsx b/app/components/modules/UserWallet.jsx index 630a73cbf7..6b2ea98395 100644 --- a/app/components/modules/UserWallet.jsx +++ b/app/components/modules/UserWallet.jsx @@ -1,6 +1,7 @@ /* eslint react/prop-types: 0 */ import React from 'react'; import {connect} from 'react-redux' +import {Link} from 'react-router' import g from 'app/redux/GlobalReducer' import SavingsWithdrawHistory from 'app/components/elements/SavingsWithdrawHistory'; import TransferHistoryRow from 'app/components/cards/TransferHistoryRow'; @@ -13,6 +14,11 @@ import {steemTip, powerTip, valueTip, savingsTip} from 'app/utils/Tips' import {numberWithCommas, vestingSteem} from 'app/utils/StateFunctions' import FoundationDropdownMenu from 'app/components/elements/FoundationDropdownMenu' import WalletSubMenu from 'app/components/elements/WalletSubMenu' +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; +import Tooltip from 'app/components/elements/Tooltip' +import { translate } from 'app/Translator'; + +const assetPrecision = 1000; class UserWallet extends React.Component { constructor() { @@ -28,18 +34,21 @@ class UserWallet extends React.Component { this.setState({showDeposit: !this.state.showDeposit, depositType: 'VESTS'}) } // this.onShowDeposit = this.onShowDeposit.bind(this) + this.shouldComponentUpdate = shouldComponentUpdate(this, 'UserWallet'); } render() { - const {state: {showDeposit, depositType, toggleDivestError}, onShowDeposit, onShowDepositSteem, onShowDepositPower} = this - const {convertToSteem, price_per_steem, savings_withdraws} = this.props - let account = this.props.account; - let current_user = this.props.current_user; - let gprops = this.props.global.getIn( ['props'] ).toJS(); + const {state: {showDeposit, depositType, toggleDivestError}, + onShowDeposit, onShowDepositSteem, onShowDepositPower} = this + const {convertToSteem, price_per_steem, savings_withdraws, account, + current_user, open_orders} = this.props + const gprops = this.props.gprops.toJS(); + + if (!account) return null; - let vesting_steemf = vestingSteem(account, gprops); + let vesting_steemf = vestingSteem(account.toJS(), gprops); let vesting_steem = vesting_steemf.toFixed(3); - let isMyAccount = current_user && current_user.get('username') === account.name; + let isMyAccount = current_user && current_user.get('username') === account.get('name'); const disabledWarning = false; // isMyAccount = false; // false to hide wallet transactions @@ -47,17 +56,18 @@ class UserWallet extends React.Component { const showTransfer = (asset, transferType, e) => { e.preventDefault(); this.props.showTransfer({ - to: (isMyAccount ? null : account.name), + to: (isMyAccount ? null : account.get('name')), asset, transferType }); }; - const {savings_balance, savings_sbd_balance} = account + const savings_balance = account.get('savings_balance'); + const savings_sbd_balance = account.get('savings_sbd_balance'); const powerDown = (cancel, e) => { e.preventDefault() - const {name} = account - const vesting_shares = cancel ? '0.000000 VESTS' : account.vesting_shares + const name = account.get('name'); + const vesting_shares = cancel ? '0.000000 VESTS' : account.get('vesting_shares') this.setState({toggleDivestError: null}) const errorCallback = e2 => {this.setState({toggleDivestError: e2.toString()})} const successCallback = () => {this.setState({toggleDivestError: null})} @@ -77,13 +87,25 @@ class UserWallet extends React.Component { }) } - const balance_steem = parseFloat(account.balance.split(' ')[0]); + const balance_steem = parseFloat(account.get('balance').split(' ')[0]); const saving_balance_steem = parseFloat(savings_balance.split(' ')[0]); const total_steem = (vesting_steemf + balance_steem + saving_balance_steem + savings_pending).toFixed(3); - const divesting = parseFloat(account.vesting_withdraw_rate.split(' ')[0]) > 0.000000; - const sbd_balance = parseFloat(account.sbd_balance) + const divesting = parseFloat(account.get('vesting_withdraw_rate').split(' ')[0]) > 0.000000; + const sbd_balance = parseFloat(account.get('sbd_balance')) const sbd_balance_savings = parseFloat(savings_sbd_balance.split(' ')[0]); const total_sbd = sbd_balance + sbd_balance_savings + savings_sbd_pending + const sbdOrders = (!open_orders || !isMyAccount) ? 0 : open_orders.reduce((o, order) => { + if (order.sell_price.base.indexOf("SBD") !== -1) { + o += order.for_sale; + } + return o; + }, 0) / assetPrecision; + const steemOrders = (!open_orders || !isMyAccount) ? 0 : open_orders.reduce((o, order) => { + if (order.sell_price.base.indexOf("STEEM") !== -1) { + o += order.for_sale; + } + return o; + }, 0) / assetPrecision; // set displayed estimated value let total_value = '$' + numberWithCommas( @@ -98,18 +120,20 @@ class UserWallet extends React.Component { /// transfer log let idx = 0 - const transfer_log = account.transfer_history.map(item => { - const data = item[1].op[1] + const transfer_log = account.get('transfer_history') + .map(item => { + const data = item.getIn([1, 'op', 1]); + const type = item.getIn([1, 'op', 0]); + // Filter out rewards - if (item[1].op[0] === "curation_reward" || item[1].op[0] === "author_reward") { + if (type === "curation_reward" || type === "author_reward") { return null; } if(data.sbd_payout === '0.000 SBD' && data.vesting_payout === '0.000000 VESTS') return null - return ; - }).filter(el => !!el); - transfer_log.reverse(); + return ; + }).filter(el => !!el).reverse(); let steem_menu = [ { value: 'Transfer', link: '#', onClick: showTransfer.bind( this, 'STEEM', 'Transfer to Account' ) }, @@ -134,7 +158,7 @@ class UserWallet extends React.Component { { value: 'Buy or Sell', link: '/market' }, { value: 'Convert to STEEM', link: '#', onClick: convertToSteem }, ] - const isWithdrawScheduled = new Date(account.next_vesting_withdrawal + 'Z').getTime() > Date.now() + const isWithdrawScheduled = new Date(account.get('next_vesting_withdrawal') + 'Z').getTime() > Date.now() const depositReveal = showDeposit &&
    @@ -143,8 +167,10 @@ class UserWallet extends React.Component {
    const steem_balance_str = numberWithCommas(balance_steem.toFixed(3)) // formatDecimal(balance_steem, 3) + const steem_orders_balance_str = numberWithCommas(steemOrders.toFixed(3)) const power_balance_str = numberWithCommas(vesting_steem) // formatDecimal(vesting_steem, 3) const sbd_balance_str = numberWithCommas('$' + sbd_balance.toFixed(3)) // formatDecimal(account.sbd_balance, 3) + const sbd_orders_balance_str = numberWithCommas('$' + sbdOrders.toFixed(3)) const savings_balance_str = numberWithCommas(saving_balance_steem.toFixed(3) + ' STEEM') const savings_sbd_balance_str = numberWithCommas('$' + sbd_balance_savings.toFixed(3)) @@ -162,7 +188,7 @@ class UserWallet extends React.Component { return (
    - {isMyAccount ? :

    BALANCES


    } + {isMyAccount ? :

    BALANCES


    }
    {isMyAccount && } @@ -176,6 +202,7 @@ class UserWallet extends React.Component { {isMyAccount ? : steem_balance_str + ' STEEM'} + {steemOrders ?
    (+{steem_orders_balance_str} STEEM)
    : null}
    @@ -196,6 +223,7 @@ class UserWallet extends React.Component { {isMyAccount ? : sbd_balance_str} + {sbdOrders ?
    (+{sbd_orders_balance_str})
    : null}
    @@ -228,7 +256,7 @@ class UserWallet extends React.Component {
    - {isWithdrawScheduled && The next power down is scheduled to happen  . } + {isWithdrawScheduled && The next power down is scheduled to happen  . } {/*toggleDivestError &&
    {toggleDivestError}
    */}
    @@ -275,12 +303,15 @@ export default connect( price_per_steem = parseFloat(base.split(' ')[0]) } const savings_withdraws = state.user.get('savings_withdraws') - const sbd_interest = state.global.get('props').get('sbd_interest_rate') + const gprops = state.global.get('props'); + const sbd_interest = gprops.get('sbd_interest_rate') return { ...ownProps, + open_orders: state.market.get('open_orders'), price_per_steem, savings_withdraws, - sbd_interest + sbd_interest, + gprops } }, // mapDispatchToProps diff --git a/app/components/pages/Market.jsx b/app/components/pages/Market.jsx index f303231811..0cf12ee685 100644 --- a/app/components/pages/Market.jsx +++ b/app/components/pages/Market.jsx @@ -21,6 +21,24 @@ class Market extends React.Component { user: React.PropTypes.string, }; + constructor(props) { + super(props); + this.state = { + buy_disabled: true, + sell_disabled: true, + buy_price_warning: false, + sell_price_warning: false, + }; + } + + componentWillReceiveProps(np) { + if (!this.props.ticker && np.ticker) { + const {lowest_ask, highest_bid} = np.ticker; + if (this.refs.buySteem_price) this.refs.buySteem_price.value = parseFloat(lowest_ask).toFixed(6); + if (this.refs.sellSteem_price) this.refs.sellSteem_price.value = parseFloat(highest_bid).toFixed(6); + } + } + shouldComponentUpdate = (nextProps, nextState) => { if( this.props.user !== nextProps.user && nextProps.user) { this.props.reload(nextProps.user) @@ -60,7 +78,8 @@ class Market extends React.Component { const amount_to_sell = parseFloat(ReactDOM.findDOMNode(this.refs.buySteem_total).value) const min_to_receive = parseFloat(ReactDOM.findDOMNode(this.refs.buySteem_amount).value) const price = (amount_to_sell / min_to_receive).toFixed(6) - placeOrder(user, amount_to_sell + " SBD", min_to_receive + " STEEM", "$" + price + "/STEEM", (msg) => { + const {lowest_ask} = this.props.ticker; + placeOrder(user, amount_to_sell + " SBD", min_to_receive + " STEEM", "$" + price + "/STEEM", !!this.state.buy_price_warning, lowest_ask, (msg) => { this.props.notify(msg) this.props.reload(user) }) @@ -72,7 +91,8 @@ class Market extends React.Component { const min_to_receive = parseFloat(ReactDOM.findDOMNode(this.refs.sellSteem_total).value) const amount_to_sell = parseFloat(ReactDOM.findDOMNode(this.refs.sellSteem_amount).value) const price = (min_to_receive / amount_to_sell).toFixed(6) - placeOrder(user, amount_to_sell + " STEEM", min_to_receive + " SBD", "$" + price + "/STEEM", (msg) => { + const {highest_bid} = this.props.ticker; + placeOrder(user, amount_to_sell + " STEEM", min_to_receive + " SBD", "$" + price + "/STEEM", !!this.state.sell_price_warning, highest_bid, (msg) => { this.props.notify(msg) this.props.reload(user) }) @@ -103,9 +123,9 @@ class Market extends React.Component { this.validateSellSteem() } - percentDiff = (a, b) => { - console.log(200 * Math.abs(a - b) / (a + b)) - return 200 * Math.abs(a - b) / (a + b) + percentDiff = (marketPrice, userPrice) => { + marketPrice = parseFloat(marketPrice); + return 100 * (userPrice - marketPrice) / (marketPrice) } validateBuySteem = () => { @@ -113,7 +133,8 @@ class Market extends React.Component { const price = parseFloat(this.refs.buySteem_price.value) const total = parseFloat(this.refs.buySteem_total.value) const valid = (amount > 0 && price > 0 && total > 0) - this.setState({buy_disabled: !valid, buy_price_warning: valid && this.percentDiff(total/amount, price) > 1 }); + const {lowest_ask} = this.props.ticker; + this.setState({buy_disabled: !valid, buy_price_warning: valid && this.percentDiff(lowest_ask, price) > 15 }); } validateSellSteem = () => { @@ -121,20 +142,10 @@ class Market extends React.Component { const price = parseFloat(this.refs.sellSteem_price.value) const total = parseFloat(this.refs.sellSteem_total.value) const valid = (amount > 0 && price > 0 && total > 0) - this.setState({sell_disabled: !valid, sell_price_warning: valid && this.percentDiff(total/amount, price) > 1 }); - } - - constructor(props) { - super(props); - this.state = { - buy_disabled: true, - sell_disabled: true, - buy_price_warning: false, - sell_price_warning: false, - }; + const {highest_bid} = this.props.ticker; + this.setState({sell_disabled: !valid, sell_price_warning: valid && this.percentDiff(highest_bid, price) < -15 }); } - render() { const {sellSteem, buySteem, cancelOrderClick, setFormPrice, validateBuySteem, validateSellSteem} = this @@ -200,7 +211,7 @@ class Market extends React.Component { }, {}) } - let account = this.props.account + let account = this.props.account ? this.props.account.toJS() : null; let open_orders = this.props.open_orders; let orderbook = aggOrders(normalizeOrders(this.props.orderbook)); @@ -313,7 +324,7 @@ class Market extends React.Component {
    - { const amount = parseFloat(this.refs.buySteem_amount.value) const price = parseFloat(this.refs.buySteem_price.value) @@ -403,7 +414,7 @@ class Market extends React.Component {
    - { const amount = parseFloat(this.refs.sellSteem_amount.value) const price = parseFloat(this.refs.sellSteem_price.value) @@ -551,7 +562,7 @@ module.exports = { //successCallback })) }, - placeOrder: (owner, amount_to_sell, min_to_receive, effectivePrice, successCallback, fill_or_kill = false, expiration = DEFAULT_EXPIRE) => { + placeOrder: (owner, amount_to_sell, min_to_receive, effectivePrice, priceWarning, marketPrice, successCallback, fill_or_kill = false, expiration = DEFAULT_EXPIRE) => { // create_order jsc 12345 "1.000 SBD" "100.000 STEEM" true 1467122240 false // Padd amounts to 3 decimal places @@ -560,17 +571,20 @@ module.exports = { min_to_receive = min_to_receive.replace(min_to_receive.split(' ')[0], String(parseFloat(min_to_receive).toFixed(3))) - const confirmStr = /STEEM$/.test(amount_to_sell) ? + const isSell = /STEEM$/.test(amount_to_sell); + const confirmStr = isSell ? `Sell ${amount_to_sell} for at least ${min_to_receive} (${effectivePrice})` : `Buy at least ${min_to_receive} for ${amount_to_sell} (${effectivePrice})` const successMessage = `Order placed: ${confirmStr}` const confirm = confirmStr + '?' + const warning = priceWarning ? "This price is well " + (isSell ? "below" : "above") + " the current market price of $" + parseFloat(marketPrice).toFixed(4) + "/STEEM, are you sure?" : null; const orderid = Math.floor(Date.now() / 1000) dispatch(transaction.actions.broadcastOperation({ type: 'limit_order_create', operation: {owner, amount_to_sell, min_to_receive, fill_or_kill, expiration, orderid}, //, //__config: {successMessage}}, confirm, + warning, successCallback: () => {successCallback(successMessage);} })) } diff --git a/app/components/pages/Market.scss b/app/components/pages/Market.scss index e3ddf0a974..3d872381d8 100644 --- a/app/components/pages/Market.scss +++ b/app/components/pages/Market.scss @@ -76,7 +76,7 @@ input.sell-color:hover { } input.price_warning { - color: rgba(0,0,0,0.25); + background: rgba(255, 153, 0, 0.13); } } diff --git a/app/components/pages/Post.jsx b/app/components/pages/Post.jsx index 8d8c937f2f..4604649505 100644 --- a/app/components/pages/Post.jsx +++ b/app/components/pages/Post.jsx @@ -3,22 +3,20 @@ import React from 'react'; import Comment from 'app/components/cards/Comment'; import PostFull from 'app/components/cards/PostFull'; import {connect} from 'react-redux'; -import { Link } from 'react-router'; import {sortComments} from 'app/components/cards/Comment'; -import DropdownMenu from 'app/components/elements/DropdownMenu'; -import user from 'app/redux/User' // import { Link } from 'react-router'; import FoundationDropdownMenu from 'app/components/elements/FoundationDropdownMenu'; import SvgImage from 'app/components/elements/SvgImage'; import {List} from 'immutable' import { translate } from 'app/Translator'; import { localizedCurrency } from 'app/components/elements/LocalizedCurrency'; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; class Post extends React.Component { static propTypes = { - global: React.PropTypes.object.isRequired, + content: React.PropTypes.object.isRequired, post: React.PropTypes.string, routeParams: React.PropTypes.object, location: React.PropTypes.object, @@ -33,7 +31,9 @@ class Post extends React.Component { this.showSignUp = () => { window.location = '/enter_email'; } + this.shouldComponentUpdate = shouldComponentUpdate(this, 'Post') } + componentDidMount() { if (window.location.hash.indexOf('comments') !== -1) { const comments_el = document.getElementById('comments'); @@ -57,15 +57,14 @@ class Post extends React.Component { render() { const {showSignUp} = this - const {current_user, following, signup_bonus} = this.props + const {current_user, following, signup_bonus, content} = this.props const {showNegativeComments, commentHidden, showAnyway} = this.state - let g = this.props.global; let post = this.props.post; if (!post) { const route_params = this.props.routeParams; post = route_params.username + '/' + route_params.slug; } - const dis = g.get('content').get(post); + const dis = content.get(post); if (!dis) return null; @@ -92,9 +91,9 @@ class Post extends React.Component { if( this.props.location && this.props.location.query.sort ) sort_order = this.props.location.query.sort; - sortComments( g, replies, sort_order ); + sortComments( content, replies, sort_order ); const keep = a => { - const c = g.getIn(['content', a]) + const c = content.get(a); const hide = c.getIn(['stats', 'hide']) let ignore = false if(following) { @@ -103,15 +102,35 @@ class Post extends React.Component { return !hide && !ignore } const positiveComments = replies.filter(a => keep(a)) - .map(reply => ); + .map(reply => ( + ) + ); // Not the complete hidding logic, just move to the bottom, the rest hide in-place const negativeReplies = replies.filter(a => !keep(a)); const stuffHidden = negativeReplies.length > 0 || commentHidden const negativeComments = - negativeReplies.map(reply => ); + negativeReplies.map(reply => ( + ) + ); const negativeGroup = !stuffHidden ? null : (
    @@ -145,7 +164,7 @@ class Post extends React.Component {
    - +
    {!current_user &&
    @@ -187,7 +206,7 @@ export default connect(state => { following = state.global.getIn(key, List()) } return { - global: state.global, + content: state.global.get('content'), signup_bonus: state.offchain.get('signup_bonus'), current_user, following, diff --git a/app/components/pages/PostsIndex.jsx b/app/components/pages/PostsIndex.jsx index 4453a496b3..a9d7ad31fe 100644 --- a/app/components/pages/PostsIndex.jsx +++ b/app/components/pages/PostsIndex.jsx @@ -9,11 +9,14 @@ import {isFetchingOrRecentlyUpdated} from 'app/utils/StateFunctions'; import {Link} from 'react-router'; import MarkNotificationRead from 'app/components/elements/MarkNotificationRead'; import { translate } from 'app/Translator'; +import Immutable from "immutable"; +import Callout from 'app/components/elements/Callout'; class PostsIndex extends React.Component { static propTypes = { discussions: PropTypes.object, + accounts: PropTypes.object, status: PropTypes.object, routeParams: PropTypes.object, requestData: PropTypes.func, @@ -69,7 +72,7 @@ class PostsIndex extends React.Component { const account_name = order.slice(1); order = 'by_feed'; topics_order = 'trending'; - posts = this.props.global.getIn(['accounts', account_name, 'feed']); + posts = this.props.accounts.getIn([account_name, 'feed']); const isMyAccount = this.props.current_user && this.props.current_user.get('username') === account_name; if (isMyAccount) { emptyText =
    @@ -80,12 +83,12 @@ class PostsIndex extends React.Component {
    ; markNotificationRead = } else { - emptyText = translate('user_hasnt_followed_anything_yet', {name: account_name}); + emptyText =
    {translate('user_hasnt_followed_anything_yet', {name: account_name})}
    ; } } else { posts = this.getPosts(order, category); if (posts !== null && posts.size === 0) { - emptyText = `No ` + topics_order + (category ? ` #` + category : '') + ` posts found`; + emptyText =
    {`No ` + topics_order + (category ? ` #` + category : '') + ` posts found`}
    ; } } @@ -100,13 +103,15 @@ class PostsIndex extends React.Component {
    {markNotificationRead} - + {(!fetching && (posts && !posts.size)) ? {emptyText} : + }
    @@ -125,7 +130,7 @@ module.exports = { discussions: state.global.get('discussion_idx'), status: state.global.get('status'), loading: state.app.get('loading'), - global: state.global, + accounts: state.global.get('accounts'), current_user: state.user.get('current') }; }, diff --git a/app/components/pages/UserProfile.jsx b/app/components/pages/UserProfile.jsx index d5304d9c47..285b377cbf 100644 --- a/app/components/pages/UserProfile.jsx +++ b/app/components/pages/UserProfile.jsx @@ -27,6 +27,8 @@ import DateJoinWrapper from 'app/components/elements/DateJoinWrapper'; import { translate } from 'app/Translator'; import WalletSubMenu from 'app/components/elements/WalletSubMenu'; import Userpic from 'app/components/elements/Userpic'; +import Callout from 'app/components/elements/Callout'; +import normalizeProfile from 'app/utils/NormalizeProfile'; export default class UserProfile extends React.Component { constructor() { @@ -36,6 +38,34 @@ export default class UserProfile extends React.Component { this.loadMore = this.loadMore.bind(this); } + shouldComponentUpdate(np) { + const {follow} = this.props; + let followersLoading = false, npFollowersLoading = false; + let followingLoading = false, npFollowingLoading = false; + + const account = np.routeParams.accountname.toLowerCase(); + if (follow) { + followersLoading = follow.getIn(['get_followers', account, 'blog', 'loading'], false); + followingLoading = follow.getIn(['get_following', account, 'blog', 'loading'], false); + } + if (np.follow) { + npFollowersLoading = np.follow.getIn(['get_followers', account, 'blog', 'loading'], false); + npFollowingLoading = np.follow.getIn(['get_following', account, 'blog', 'loading'], false); + } + + return ( + np.current_user !== this.props.current_user || + np.accounts.get(account) !== this.props.accounts.get(account) || + np.wifShown !== this.props.wifShown || + np.global_status !== this.props.global_status || + ((npFollowersLoading !== followersLoading) && !npFollowersLoading) || + ((npFollowingLoading !== followingLoading) && !npFollowingLoading) || + np.loading !== this.props.loading || + np.location.pathname !== this.props.location.pathname || + np.routeParams.accountname !== this.props.routeParams.accountname + ) + } + componentWillUnmount() { this.props.clearTransferDefaults() } @@ -53,14 +83,14 @@ export default class UserProfile extends React.Component { default: console.log('unhandled category:', category); } - if (isFetchingOrRecentlyUpdated(this.props.global.get('status'), order, category)) return; + if (isFetchingOrRecentlyUpdated(this.props.global_status, order, category)) return; const [author, permlink] = last_post.split('/'); this.props.requestData({author, permlink, order, category, accountname}); } render() { const { - props: {current_user, wifShown}, + props: {current_user, wifShown, global_status, follow}, onPrint } = this; let { accountname, section } = this.props.routeParams; @@ -75,7 +105,7 @@ export default class UserProfile extends React.Component { // const isMyAccount = current_user ? current_user.get('username') === accountname : false; let account - let accountImm = this.props.global.getIn(['accounts', accountname]); + let accountImm = this.props.accounts.get(accountname); if( accountImm ) { account = accountImm.toJS(); } @@ -84,16 +114,13 @@ export default class UserProfile extends React.Component { } let followerCount, followingCount; - const followers = this.props.global.getIn( ['follow', 'get_followers', accountname] ); - const following = this.props.global.getIn( ['follow', 'get_following', accountname] ); - + const followers = follow ? follow.getIn( ['get_followers', accountname] ) : null; + const following = follow ? follow.getIn( ['get_following', accountname] ) : null; if(followers && followers.has('result') && followers.has('blog')) { const status_followers = followers.get('blog') const followers_loaded = status_followers.get('loading') === false && status_followers.get('error') == null if (followers_loaded) { - followerCount = followers.get('result').filter(a => { - return a.get(0) === "blog"; - }).size; + followerCount = followers.get('count'); } } @@ -101,19 +128,16 @@ export default class UserProfile extends React.Component { const status_following = following.get('blog') const following_loaded = status_following.get('loading') === false && status_following.get('error') == null if (following_loaded) { - followingCount = following.get('result').filter(a => { - return a.get(0) === "blog"; - }).size; + followingCount = following.get('count'); } } const rep = repLog10(account.reputation); const isMyAccount = username === account.name - const name = account.name; let tab_content = null; - const global_status = this.props.global.get('status'); + // const global_status = this.props.global.get('status'); const status = global_status ? global_status.getIn([section, 'by_author']) : null; const fetching = (status && status.fetching) || this.props.loading; @@ -128,8 +152,8 @@ export default class UserProfile extends React.Component { if( section === 'transfers' ) { walletClass = 'active' tab_content =
    - @@ -138,14 +162,14 @@ export default class UserProfile extends React.Component { } else if( section === 'curation-rewards' ) { rewardsClass = "active"; - tab_content = } else if( section === 'author-rewards' ) { rewardsClass = "active"; - tab_content = @@ -178,49 +202,75 @@ export default class UserProfile extends React.Component { // -- see also GlobalReducer.js if( account.posts || account.comments ) { - tab_content = ; + let posts = accountImm.get('posts') || accountImm.get('comments'); + if (!fetching && (posts && !posts.size)) { + tab_content = {translate('user_hasnt_made_any_posts_yet', {name: accountname})}; + } else { + tab_content = ( + + ); + } } else { tab_content = (
    ); } } else if(!section || section === 'blog') { if (account.blog) { + let posts = accountImm.get('blog'); const emptyText = isMyAccount ?
    Looks like you haven't posted anything yet.

    Submit a Story
    Read The Beginner's Guide
    Read The Steemit Welcome Guide
    : -
    {translate('user_hasnt_started_bloggin_yet', {name})}
    ; - tab_content = ; + translate('user_hasnt_started_bloggin_yet', {name: accountname}); + + if (!fetching && (posts && !posts.size)) { + tab_content = {emptyText}; + } else { + tab_content = ( + + ); + } } else { tab_content = (
    ); } } - else if( (section === 'recent-replies') && account.recent_replies ) { - tab_content =
    - - {isMyAccount && } -
    ; + else if( (section === 'recent-replies')) { + if (account.recent_replies) { + let posts = accountImm.get('recent_replies'); + if (!fetching && (posts && !posts.size)) { + tab_content = {translate('user_hasnt_had_any_replies_yet', {name: accountname}) + '.'}; + } else { + tab_content = ( +
    + + {isMyAccount && } +
    + ); + } + } else { + tab_content = (
    ); + } } else if( section === 'permissions' && isMyAccount ) { walletClass = 'active' @@ -316,6 +366,9 @@ export default class UserProfile extends React.Component {
    ; + const {name, location, about, website} = normalizeProfile(account); + const website_label = website ? website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') : null + return (
    @@ -323,17 +376,21 @@ export default class UserProfile extends React.Component {
    -
    +
    -

    + +

    - {account.name}{' '} - ({rep}) -

    + {name || account.name}{' '} + + ({rep}) + +
    + {about &&

    {about}

    }
    {followerCount ? translate('follower_count', {followerCount}) : translate('followers')} @@ -342,8 +399,15 @@ export default class UserProfile extends React.Component { {translate('post_count', {postCount: account.post_count || 0})} {followingCount ? translate('followed_count', {followingCount}) : translate('following')}
    +

    + {location && {location}} + {website && {website_label}} + +

    +
    +
    +
    -
    @@ -367,13 +431,16 @@ module.exports = { const wifShown = state.global.get('UserKeys_wifShown') const current_user = state.user.get('current') // const current_account = current_user && state.global.getIn(['accounts', current_user.get('username')]) + return { discussions: state.global.get('discussion_idx'), - global: state.global, current_user, // current_account, wifShown, - loading: state.app.get('loading') + loading: state.app.get('loading'), + global_status: state.global.get('status'), + accounts: state.global.get('accounts'), + follow: state.global.get('follow') }; }, dispatch => ({ diff --git a/app/components/pages/UserProfile.scss b/app/components/pages/UserProfile.scss index 70925cdee8..9ae57730a8 100644 --- a/app/components/pages/UserProfile.scss +++ b/app/components/pages/UserProfile.scss @@ -65,14 +65,26 @@ background: #23579d; /* for older browsers */ background: linear-gradient(to bottom, #1a4072 0%, #23579d 100%); - height: 155px; + min-height: 155px; } - h2 { + h3 { padding-top: 20px; - .Userpic { - margin-right: 1rem; - vertical-align: middle; - } + font-weight: 600; + } + + .Icon { + margin-left: 1rem; + svg {fill: #def;} + } + + .Userpic { + margin-right: 0.75rem; + vertical-align: middle; + } + + .UserProfile__rep { + font-size: 80%; + font-weight: 200; } .UserProfile__buttons { @@ -87,22 +99,30 @@ } } + .UserProfile__bio { + margin: -0.4rem auto 0.5rem; + font-size: 95%; + max-width: 420px; + line-height: 1.4; + } + .UserProfile__info { + font-size: 90%; + } + .UserProfile__stats { margin-bottom: 5px; padding-bottom: 5px; + font-size: 90%; a { @include hoverUnderline; vertical-align: middle; } - span { + > span { padding: 0px 10px; - } - - span:nth-child(2) { - border-left: 1px solid grey; - border-right: 1px solid grey; + border-left: 1px solid #CCC; + &:first-child {border-left: none;} } .NotifiCounter { @@ -129,7 +149,7 @@ padding-right: 0; } - .UserProfile__banner h2 .Userpic { + .UserProfile__banner .Userpic { width: 36px !important; height: 36px !important; } @@ -142,6 +162,15 @@ } } + .UserProfile__banner .UserProfile__buttons_mobile { + position: inherit; + margin-bottom: .5rem; + .button { + background-color: $white; + color: $black; + } + } + .UserWallet__balance { > div:last-of-type { text-align: left; diff --git a/app/components/pages/Witnesses.jsx b/app/components/pages/Witnesses.jsx index 3c910da090..21dde3c152 100644 --- a/app/components/pages/Witnesses.jsx +++ b/app/components/pages/Witnesses.jsx @@ -5,7 +5,7 @@ import links from 'app/utils/Links' import Icon from 'app/components/elements/Icon'; import transaction from 'app/redux/Transaction' import ByteBuffer from 'bytebuffer' -import {Set} from 'immutable' +import {Set, is} from 'immutable' import { translate } from 'app/Translator'; const Long = ByteBuffer.Long @@ -16,7 +16,7 @@ class Witnesses extends React.Component { // HTML properties // Redux connect properties - global: object.isRequired, + witnesses: object.isRequired, accountWitnessVote: func.isRequired, username: string, witness_votes: object, @@ -36,20 +36,20 @@ class Witnesses extends React.Component { } } + shouldComponentUpdate(np, ns) { + return ( + !is(np.witness_votes, this.props.witness_votes) || + np.witnesses !== this.props.witnesses || + np.username !== this.props.username || + ns.customUsername !== this.state.customUsername + ); + } + render() { - const {props: {global, witness_votes}, state: {customUsername}, accountWitnessVote, onWitnessChange} = this - const sorted_witnesses = global.getIn(['witnesses']) + const {props: {witness_votes}, state: {customUsername}, accountWitnessVote, onWitnessChange} = this + const sorted_witnesses = this.props.witnesses .sort((a, b) => Long.fromString(String(b.get('votes'))).subtract(Long.fromString(String(a.get('votes'))).toString())); - const header = -
    -
    - -
    -
    - -
    -
    const up = ; let witness_vote_count = 30 let rank = 1 @@ -62,7 +62,7 @@ class Witnesses extends React.Component { let witness_thread = "" if(thread) { if(links.remote.test(thread)) { - witness_thread = {translate('witness_thread')}  + witness_thread = {translate('witness_thread')}  } else { witness_thread = {translate('witness_thread')} } @@ -164,7 +164,7 @@ module.exports = { const current_account = current_user && state.global.getIn(['accounts', username]) const witness_votes = current_account && Set(current_account.get('witness_votes')) return { - global: state.global, + witnesses: state.global.get('witnesses'), username, witness_votes, }; diff --git a/app/locales/en.js b/app/locales/en.js index 6bfaadd67a..0c4b4d3576 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -546,7 +546,11 @@ const en = { by_verifying_you_agree_with: 'By verifying your account you agree to the', by_verifying_you_agree_with_privacy_policy: 'Privacy Policy', by_verifying_you_agree_with_privacy_policy_of_website_APP_URL: 'of ' + APP_URL, - add_image_url: 'Profile picture url', + profile_image_url: 'Profile picture url', + profile_name: 'Display Name', + profile_about: 'About', + profile_location: 'Location', + profile_website: 'Website', saved: 'Saved', server_returned_error: 'server returned error', } diff --git a/app/locales/ru.js b/app/locales/ru.js index 370af809a4..b2c70e0d87 100644 --- a/app/locales/ru.js +++ b/app/locales/ru.js @@ -563,7 +563,11 @@ const ru = { few {# неподтвержденныe транзакции} many {# неподтвержденных транзакций} }`, - add_image_url: 'Добавьте url вашего изображения', + profile_image_url: 'Добавьте url вашего изображения', + profile_name: 'Display Name', + profile_about: 'About', + profile_location: 'Location', + profile_website: 'Website', saved: 'Сохранено', server_returned_error: 'ошибка сервера', } diff --git a/app/redux/AppReducer.js b/app/redux/AppReducer.js index a9b4136c65..74e036a8b7 100644 --- a/app/redux/AppReducer.js +++ b/app/redux/AppReducer.js @@ -7,6 +7,7 @@ const defaultState = Map({ error: '', location: {}, notifications: null, + ignoredLoadingRequestCount: 0, notificounters: Map({ total: 0, feed: 0, @@ -38,13 +39,29 @@ export default function reducer(state = defaultState, action) { let res = state; if (action.type === 'RPC_REQUEST_STATUS') { const request_id = action.payload.id + ''; + const loadingBlacklist = [ + 'get_dynamic_global_properties', + 'get_api_by_name', + 'get_followers', + 'get_following' + ]; + const loadingIgnored = loadingBlacklist.indexOf(action.payload.method) !== -1; if (action.payload.event === 'BEGIN') { - res = state.mergeDeep({loading: true, requests: {[request_id]: Date.now()}}); + res = state.mergeDeep({ + loading: loadingIgnored ? false : true, + requests: {[request_id]: Date.now()}, + ignoredLoadingRequestCount: state.get('ignoredLoadingRequestCount') + (loadingIgnored ? 1 : 0) + }); } if (action.payload.event === 'END' || action.payload.event === 'ERROR') { + const ignoredLoadingRequestCount = state.get('ignoredLoadingRequestCount') - (loadingIgnored ? 1 : 0); res = res.deleteIn(['requests', request_id]); - const loading = res.get('requests').size > 0; - res = res.set('loading', loading); + // console.log("RPC_REQUEST END:", action.payload.method, res.get('requests').size, "ignoredLoadingRequestCount", ignoredLoadingRequestCount); + const loading = (res.get('requests').size - ignoredLoadingRequestCount) > 0; + res = res.mergeDeep({ + loading, + ignoredLoadingRequestCount + }); } } if (action.type === 'ADD_NOTIFICATION') { diff --git a/app/redux/FetchDataSaga.js b/app/redux/FetchDataSaga.js index e1bd30ca9d..31d8b1bcd7 100644 --- a/app/redux/FetchDataSaga.js +++ b/app/redux/FetchDataSaga.js @@ -14,7 +14,7 @@ export function* watchDataRequests() { export function* fetchState(location_change_action) { const {pathname} = location_change_action.payload; - const m = pathname.match(/@([a-z0-9\.-]+)/) + const m = pathname.match(/^\/@([a-z0-9\.-]+)/) if(m && m.length === 2) { const username = m[1] const hasFollows = yield select(state => state.global.hasIn(['follow', 'get_followers', username])) @@ -26,13 +26,6 @@ export function* fetchState(location_change_action) { const server_location = yield select(state => state.offchain.get('server_location')); if (pathname === server_location) return; - // virtual pageview - const {ga} = window - if(ga) { - ga('set', 'page', pathname); - ga('send', 'pageview'); - } - let url = `${pathname}`; if (url === '/') url = 'trending'; // Replace /curation-rewards and /author-rewards with /transfers for UserProfile diff --git a/app/redux/FollowSaga.js b/app/redux/FollowSaga.js index 6422453c6a..88ef6b1f2a 100644 --- a/app/redux/FollowSaga.js +++ b/app/redux/FollowSaga.js @@ -4,15 +4,14 @@ import {Apis} from 'shared/api_client'; import {List} from 'immutable' // Test limit with 2 (not 1, infinate looping) -export function* loadFollows(method, follower, type, start = '', limit = 100) { - const res = fromJS(yield Apis.follow(method, follower, start, type, limit)) +export function* loadFollows(method, account, type, start = '', limit = 100) { + const res = fromJS(yield Apis.follow(method, account, start, type, limit)) // console.log('res.toJS()', res.toJS()) let cnt = 0 let lastFollowing = null const key = method === "get_following" ? "following" : "follower"; - yield put({type: 'global/UPDATE', payload: { - key: ['follow', method, follower], + key: ['follow', method, account], notSet: Map(), updater: m => { m = m.update('result', Map(), m2 => { @@ -25,14 +24,17 @@ export function* loadFollows(method, follower, type, start = '', limit = 100) { }) return m2 }) - return m.merge({[type]: {loading: true, error: null}}) + const count = m.get('result') ? m.get('result').filter(a => { + return a.get(0) === "blog"; + }).size : 0; + return m.merge({count, [type]: {loading: true, error: null}}) } }}) if(cnt === limit) { - yield call(loadFollows, method, follower, type, lastFollowing) + yield call(loadFollows, method, account, type, lastFollowing) } else { yield put({type: 'global/UPDATE', payload: { - key: ['follow', method, follower], + key: ['follow', method, account], updater: m => m.merge({[type]: {loading: false, error: null}}) }}) } diff --git a/app/redux/MarketSaga.js b/app/redux/MarketSaga.js index a7178d4472..2abc14e9e5 100644 --- a/app/redux/MarketSaga.js +++ b/app/redux/MarketSaga.js @@ -1,9 +1,10 @@ -import {takeLatest, takeEvery} from 'redux-saga'; -import {call, put, select} from 'redux-saga/effects'; +import {takeLatest} from 'redux-saga'; +import {call, put} from 'redux-saga/effects'; import Apis from 'shared/api_client/ApiInstances'; import MarketReducer from './MarketReducer'; -import constants from './constants'; -import {fromJS, Map} from 'immutable' +import g from 'app/redux/GlobalReducer' +// import constants from './constants'; +import {fromJS} from 'immutable' export const marketWatches = [watchLocationChange, watchUserLogin, watchMarketUpdate]; @@ -68,9 +69,12 @@ export function* fetchOpenOrders(set_user_action) { const state = yield call([db_api, db_api.exec], 'get_open_orders', [username]); yield put(MarketReducer.actions.receiveOpenOrders(state)); - const [account] = yield call(Apis.db_api, 'get_accounts', [username]) - yield put(MarketReducer.actions.receiveAccount({ account })) - + let [account] = yield call(Apis.db_api, 'get_accounts', [username]) + if(account) { + account = fromJS(account) + yield put(MarketReducer.actions.receiveAccount({ account })) + yield put(g.actions.receiveAccount({ account })) // TODO: move out of MarketSaga. See notes in #741 + } } catch (error) { console.error('~~ Saga fetchOpenOrders error ~~>', error); yield put({type: 'global/STEEM_API_ERROR', error: error.message}); diff --git a/app/redux/RootReducer.js b/app/redux/RootReducer.js index 4e35654f1e..80068c8edf 100644 --- a/app/redux/RootReducer.js +++ b/app/redux/RootReducer.js @@ -9,7 +9,7 @@ import user from './User'; // import auth from './AuthSaga'; import transaction from './Transaction'; import offchain from './Offchain'; -import {reducer as formReducer} from 'redux-form'; +import {reducer as formReducer} from 'redux-form'; // @deprecated, instead use: app/utils/ReactForm.js import {contentStats} from 'app/utils/StateFunctions' function initReducer(reducer, type) { diff --git a/app/redux/Transaction.js b/app/redux/Transaction.js index e4d625b0bd..34d8cfb972 100644 --- a/app/redux/Transaction.js +++ b/app/redux/Transaction.js @@ -6,7 +6,7 @@ export default createModule({ initialState: fromJS({ operations: [], status: { key: '', error: false, busy: false, }, - errors: null, + errors: null }), transformations: [ { @@ -14,11 +14,13 @@ export default createModule({ reducer: (state, {payload}) => { const operation = fromJS(payload.operation) const confirm = payload.confirm + const warning = payload.warning return state.merge({ show_confirm_modal: true, confirmBroadcastOperation: operation, confirmErrorCallback: payload.errorCallback, confirm, + warning }) } }, diff --git a/app/redux/TransactionSaga.js b/app/redux/TransactionSaga.js index 3c4bdc9c64..ef08bfae28 100644 --- a/app/redux/TransactionSaga.js +++ b/app/redux/TransactionSaga.js @@ -13,6 +13,7 @@ import user from 'app/redux/User' import tr from 'app/redux/Transaction' import getSlug from 'speakingurl' import {DEBT_TICKER} from 'config/client_config' +import {serverApiRecordEvent} from 'app/utils/ServerApiClient' const {transaction} = ops @@ -93,12 +94,12 @@ function* error_account_witness_vote({operation: {account, witness, approve}}) { /** Keys, username, and password are not needed for the initial call. This will check the login and may trigger an action to prompt for the password / key. */ function* broadcastOperation({payload: - {type, operation, confirm, keys, username, password, successCallback, errorCallback} + {type, operation, confirm, warning, keys, username, password, successCallback, errorCallback} }) { const operationParam = {type, operation, keys, username, password, successCallback, errorCallback} const conf = typeof confirm === 'function' ? confirm() : confirm if(conf) { - yield put(tr.actions.confirmOperation({confirm, operation: operationParam, errorCallback})) + yield put(tr.actions.confirmOperation({confirm, warning, operation: operationParam, errorCallback})) return } const payload = {operations: [[type, operation]], keys, username, successCallback, errorCallback} @@ -117,6 +118,8 @@ function* broadcastOperation({payload: } } yield call(broadcast, {payload}) + const eventType = type.replace(/^([a-z])/, g => g.toUpperCase()).replace(/_([a-z])/g, g => g[1].toUpperCase()); + serverApiRecordEvent(eventType, '') } catch(error) { console.error('TransactionSage', error) if(errorCallback) errorCallback(error.toString()) diff --git a/app/utils/NormalizeProfile.js b/app/utils/NormalizeProfile.js new file mode 100644 index 0000000000..79a9210b27 --- /dev/null +++ b/app/utils/NormalizeProfile.js @@ -0,0 +1,45 @@ +function truncate(str, len) { + if(str && str.length > len) { + return str.substring(0, len - 1) + '...' + } + return str +} + +/** + * Enforce profile data length & format standards. + */ +export default function normalizeProfile(account) { + + if(! account) return {} + + // Parse + let profile = {}; + if(account.json_metadata) { + try { + const md = JSON.parse(account.json_metadata); + if(md.profile) { + profile = md.profile; + } + } catch (e) { + console.error('Invalid json metadata string', account.json_metadata, 'in account', account.name); + } + } + + // Read & normalize + let {name, about, location, website, profile_image} = profile + + name = truncate(name, 20) + about = truncate(about, 160) + location = truncate(location, 30) + + if(website && website.length > 100) website = null; + if(profile_image && !/^https?:\/\//.test(profile_image)) profile_image = null; + + return { + name, + about, + location, + website, + profile_image, + }; +} diff --git a/app/utils/ReactForm.js b/app/utils/ReactForm.js index cdcff9deb6..d47ee3a431 100644 --- a/app/utils/ReactForm.js +++ b/app/utils/ReactForm.js @@ -15,19 +15,26 @@ export default function reactForm({name, instance, fields, initialValues, valida const formState = instance.state = instance.state || {} formState[name] = { - // validate: () => isValid(instance, fields, validation), - handleSubmit: (fn) => (e) => { - e.preventDefault() - const valid = isValid(name, instance, fields, validation) + // validate: () => setFormState(instance, fields, validation), + handleSubmit: submitCallback => event => { + event.preventDefault() + const {valid} = setFormState(name, instance, fields, validation) if(!valid) return const data = getData(fields, instance.state) let formValid = true const fs = instance.state[name] || {} fs.submitting = true + + // User can call this function upon successful submission + const updateInitialValues = () => { + setInitialValuesFromForm(name, instance, fields, initialValues) + formState[name].resetForm() + } + instance.setState( {[name]: fs}, () => { - const ret = fn(data) || {} + const ret = submitCallback({data, event, updateInitialValues}) || {} for(const fieldName of Object.keys(ret)) { const error = ret[fieldName] if(!error) continue @@ -73,22 +80,24 @@ export default function reactForm({name, instance, fields, initialValues, valida // Caution: fs.props is expanded , so only add valid props for the component fs.props = {name: fieldName} - const initialValue = initialValues[fieldName] - - if(fieldType === 'checked') { - fs.value = toString(initialValue) - fs.props.checked = toBoolean(initialValue) - } else if(fieldType === 'selected') { - fs.props.selected = toString(initialValue) - fs.value = fs.props.selected - } else { - fs.props.value = toString(initialValue) - fs.value = fs.props.value + { + const initialValue = initialValues[fieldName] + if(fieldType === 'checked') { + fs.value = toString(initialValue) + fs.props.checked = toBoolean(initialValue) + } else if(fieldType === 'selected') { + fs.props.selected = toString(initialValue) + fs.value = fs.props.selected + } else { + fs.props.value = toString(initialValue) + fs.value = fs.props.value + } } fs.props.onChange = e => { const value = e && e.target ? e.target.value : e // API may pass value directly const v = {...(instance.state[fieldName] || {})} + const initialValue = initialValues[fieldName] if(fieldType === 'checked') { v.touched = toString(value) !== toString(initialValue) @@ -104,7 +113,7 @@ export default function reactForm({name, instance, fields, initialValues, valida instance.setState( {[fieldName]: v}, - () => {isValid(name, instance, fields, validation)} + () => {setFormState(name, instance, fields, validation)} ) } @@ -117,8 +126,9 @@ export default function reactForm({name, instance, fields, initialValues, valida } } -function isValid(name, instance, fields, validation) { +function setFormState(name, instance, fields, validation) { let formValid = true + let formTouched = false const v = validation(getData(fields, instance.state)) for(const field of fields) { const fieldName = n(field) @@ -126,13 +136,23 @@ function isValid(name, instance, fields, validation) { const error = validate ? validate : null const value = {...(instance.state[fieldName] || {})} value.error = error + formTouched = formTouched || value.touched if(error) formValid = false instance.setState({[fieldName]: value}) } const fs = {...(instance.state[name] || {})} fs.valid = formValid + fs.touched = formTouched instance.setState({[name]: fs}) - return formValid + return fs +} + +function setInitialValuesFromForm(name, instance, fields, initialValues) { + const data = getData(fields, instance.state) + for(const field of fields) { + const fieldName = n(field) + initialValues[fieldName] = data[fieldName] + } } function getData(fields, state) { diff --git a/app/utils/ServerApiClient.js b/app/utils/ServerApiClient.js index 16e676f8b6..00bcce46ba 100644 --- a/app/utils/ServerApiClient.js +++ b/app/utils/ServerApiClient.js @@ -25,7 +25,7 @@ export function serverApiLogout() { let last_call; export function serverApiRecordEvent(type, val) { if (!process.env.BROWSER || window.$STM_ServerBusy) return; - if (last_call && (new Date() - last_call < 60000)) return; + if (last_call && (new Date() - last_call < 5000)) return; last_call = new Date(); const value = val && val.stack ? `${val.toString()} | ${val.stack}` : val; const request = Object.assign({}, request_base, {body: JSON.stringify({csrf: $STM_csrf, type, value})}); @@ -49,6 +49,22 @@ export function markNotificationRead(account, fields) { }); } +let last_page, last_views; +export function recordPageView(page, ref) { + if (page === last_page) return Promise.resolve(last_views); + if (window.ga) { // virtual pageview + window.ga('set', 'page', page); + window.ga('send', 'pageview'); + } + if (!process.env.BROWSER || window.$STM_ServerBusy) return Promise.resolve(0); + const request = Object.assign({}, request_base, {body: JSON.stringify({csrf: $STM_csrf, page, ref})}); + return fetch(`/api/v1/page_view`, request).then(r => r.json()).then(res => { + last_page = page; + last_views = res.views; + return last_views; + }); +} + if (process.env.BROWSER) { window.getNotifications = getNotifications; window.markNotificationRead = markNotificationRead; diff --git a/app/utils/shouldComponentUpdate.js b/app/utils/shouldComponentUpdate.js index c514c731db..74a82d671b 100644 --- a/app/utils/shouldComponentUpdate.js +++ b/app/utils/shouldComponentUpdate.js @@ -13,14 +13,14 @@ export default function (instance, name) { return (nextProps, nextState) => { const upd = mixin(nextProps, nextState) if (upd && process.env.BROWSER && window.steemDebug_shouldComponentUpdate) { - cmp(name, 'props', instance.props, nextProps) - cmp(name, 'state', instance.state, nextState) + cmp(name, instance.props, nextProps) + cmp(name, instance.state, nextState) } return upd } } -function cmp(name, type, a, b) { +export function cmp(name, a, b) { const aKeys = new Set(a && Object.keys(a)) const bKeys = new Set(b && Object.keys(b)) const ab = new Set([...aKeys, ...aKeys]) diff --git a/db/migrations/20161129170500-create-page.js b/db/migrations/20161129170500-create-page.js new file mode 100644 index 0000000000..d5fcde139c --- /dev/null +++ b/db/migrations/20161129170500-create-page.js @@ -0,0 +1,24 @@ +'use strict'; +module.exports = { + up: function (queryInterface, Sequelize) { + return queryInterface.createTable('pages', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + permlink: {type: Sequelize.STRING(256)}, + views: {type: Sequelize.INTEGER}, + created_at: { + allowNull: false, + type: Sequelize.DATE + } + }).then(function () { + queryInterface.addIndex('pages', ['permlink'], {indicesType: 'UNIQUE'}); + }); + }, + down: function (queryInterface, Sequelize) { + return queryInterface.dropTable('pages'); + } +}; diff --git a/db/models/page.js b/db/models/page.js new file mode 100644 index 0000000000..dfe6fb3594 --- /dev/null +++ b/db/models/page.js @@ -0,0 +1,13 @@ +module.exports = function (sequelize, DataTypes) { + var Page = sequelize.define('Page', { + permlink: DataTypes.STRING(256), + views: DataTypes.INTEGER, + }, { + tableName: 'pages', + createdAt: 'created_at', + updatedAt: false, + timestamps : true, + underscored : true + }); + return Page; +}; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a74e51b7a6..0a05f3434a 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1755,11 +1755,6 @@ "from": "fast-levenshtein@>=2.0.4 <2.1.0", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz" }, - "fastclick": { - "version": "1.0.6", - "from": "fastclick@>=1.0.6 <2.0.0", - "resolved": "https://registry.npmjs.org/fastclick/-/fastclick-1.0.6.tgz" - }, "fastparse": { "version": "1.1.1", "from": "fastparse@>=1.1.1 <2.0.0", diff --git a/package.json b/package.json index 8b34c3ae00..f8cc32a34f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "ecurve": "^1.0.2", "estraverse-fb": "^1.3.1", "extract-text-webpack-plugin": "^1.0.1", - "fastclick": "^1.0.6", "file-loader": "^0.8.5", "foundation-sites": "6.2.1", "git-rev-sync": "^1.6.0", @@ -77,6 +76,7 @@ "lodash.debounce": "^4.0.7", "medium-editor-insert-plugin": "^2.3.2", "minimist": "^1.2.0", + "mixpanel": "^0.5.0", "mysql": "^2.10.2", "net": "^1.0.2", "newrelic": "^1.33.0", diff --git a/release-notes.txt b/release-notes.txt index a772c8b00c..ac2acd34ac 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,3 +1,35 @@ +--------------------------------------------------------------------- +0.1.161202 +--------------------------------------------------------------------- + +New features +-------- + - views counter #744 + - profile customization #737 + - full power badge #748 + - add current open orders to wallet balances #740 + +Bug fixes +-------- + - various market bug fixes and price warning #728 + - performance tweaks: minimize rendering and API calls #738 + - fix witness votes not appearing for logged in user #741 + - add support for vimeo auto embed #731 + - fix obscure bug which causes certain keys to trigger back event #754 + - fix follow mute button alignment for mobile display #753 + - do not show dropdown for comments with 0 votes #747 + - fix bug preventing declined payout post from being edited #743 + - handle malformed categories in url #742 + - fix share menu scrolling behavior #739 + - adjust password data-entry error wording #736 + - clarify dangerous-html flag usage #733 + - remove fastclick for JS dropdown conflicts #727 + - allow links to open in new tab without closing menu #726 + - add padding for avatar on collapsed state #717 + - display previous title when closing post modal #709 + - remove negative top margin on comment footer #714 + + --------------------------------------------------------------------- 0.1.161123 --------------------------------------------------------------------- diff --git a/server/api/general.js b/server/api/general.js index 9acecd13f7..e8b15b572c 100644 --- a/server/api/general.js +++ b/server/api/general.js @@ -7,8 +7,12 @@ import recordWebEvent from 'server/record_web_event'; import {esc, escAttrs} from 'db/models'; import {emailRegex, getRemoteIp, rateLimitReq, checkCSRF} from 'server/utils'; import coBody from 'co-body'; +import Mixpanel from 'mixpanel'; import Tarantool from 'db/tarantool'; +const mixpanel = config.mixpanel ? Mixpanel.init(config.mixpanel) : null; + + export default function useGeneralApi(app) { const router = koa_router({prefix: '/api/v1'}); app.use(router.routes()); @@ -144,6 +148,13 @@ export default function useGeneralApi(app) { })).catch(error => { console.error('!!! Can\'t create account model in /accounts api', this.session.uid, error); }); + if (mixpanel) { + mixpanel.track('Signup', { + distinct_id: this.session.uid, + ip: remote_ip + }); + mixpanel.people.set(this.session.uid, {ip: remote_ip}); + } } catch (error) { console.error('Error in /accounts api call', this.session.uid, error.toString()); this.body = JSON.stringify({error: error.message}); @@ -189,10 +200,15 @@ export default function useGeneralApi(app) { try { this.session.a = account; const db_account = yield models.Account.findOne( - {attributes: ['user_id'], where: {name: esc(account)}} + {attributes: ['user_id'], where: {name: esc(account)}, logging: false} ); if (db_account) this.session.user = db_account.user_id; this.body = JSON.stringify({status: 'ok'}); + const remote_ip = getRemoteIp(this.req); + if (mixpanel) { + mixpanel.people.set(this.session.uid, {ip: remote_ip, $ip: remote_ip}); + mixpanel.people.increment(this.session.uid, 'Visits', 1); + } } catch (error) { console.error('Error in /login_account api call', this.session.uid, error.message); this.body = JSON.stringify({error: error.message}); @@ -224,9 +240,14 @@ export default function useGeneralApi(app) { const {csrf, type, value} = typeof(params) === 'string' ? JSON.parse(params) : params; if (!checkCSRF(this, csrf)) return; console.log('-- /record_event -->', this.session.uid, type, value); - const str_value = typeof value === 'string' ? value : JSON.stringify(value); + if (type.match(/^[A-Z]/)) { + mixpanel.track(type, {distinct_id: this.session.uid}); + mixpanel.people.increment(this.session.uid, type, 1); + } else { + const str_value = typeof value === 'string' ? value : JSON.stringify(value); + recordWebEvent(this, type, str_value); + } this.body = JSON.stringify({status: 'ok'}); - recordWebEvent(this, type, str_value); } catch (error) { console.error('Error in /record_event api call', error.message); this.body = JSON.stringify({error: error.message}); @@ -240,6 +261,54 @@ export default function useGeneralApi(app) { console.log('-- /csp_violation -->', this.req.headers['user-agent'], params); this.body = ''; }); + + router.post('/page_view', koaBody, function *() { + const params = this.request.body; + const {csrf, page, ref} = typeof(params) === 'string' ? JSON.parse(params) : params; + if (!checkCSRF(this, csrf)) return; + console.log('-- /page_view -->', this.session.uid, page); + const remote_ip = getRemoteIp(this.req); + try { + let views = 1, unique = true; + if (config.tarantool) { + const res = yield Tarantool.instance().call('page_view', page, remote_ip, this.session.uid, ref); + unique = res[0][0]; + } + const page_model = yield models.Page.findOne( + {attributes: ['id', 'views'], where: {permlink: esc(page)}, logging: false} + ); + if (unique) { + if (page_model) { + views = page_model.views + 1; + yield yield models.Page.update({views}, {where: {id: page_model.id}, logging: false}); + } else { + yield models.Page.create(escAttrs({permlink: page, views}), {logging: false}); + } + } + this.body = JSON.stringify({views}); + if (mixpanel) { + let referring_domain = ''; + if (ref) { + const matches = ref.match(/^https?\:\/\/([^\/?#]+)(?:[\/?#]|$)/i); + referring_domain = matches && matches[1]; + } + mixpanel.track('PageView', { + distinct_id: this.session.uid, + Page: page, + ip: remote_ip, + $referrer: ref, + $referring_domain: referring_domain + }); + if (ref) mixpanel.people.set_once(this.session.uid, '$referrer', ref); + mixpanel.people.set_once(this.session.uid, 'FirstPage', page); + mixpanel.people.increment(this.session.uid, 'PageView', 1); + } + } catch (error) { + console.error('Error in /page_view api call', this.session.uid, error.message); + this.body = JSON.stringify({error: error.message}); + this.status = 500; + } + }); } import {Apis} from 'shared/api_client'; diff --git a/server/server.js b/server/server.js index 9fe3079e11..800e7ed7d4 100644 --- a/server/server.js +++ b/server/server.js @@ -54,6 +54,16 @@ app.use(function *(next) { return; } } + // normalize top category filtering from cased params + if (this.method === 'GET' && /^\/(hot|created|trending|active)\//.test(this.url)) { + const segments = this.url.split('/') + const category = segments[2] + if(category !== category.toLowerCase()) { + segments[2] = category.toLowerCase() + this.redirect(segments.join('/')); + return; + } + } // start registration process if user get to create_account page and has no id in session yet if(this.url === '/create_account' && !this.session.user) { this.status = 302; diff --git a/shared/HtmlReady.js b/shared/HtmlReady.js index baa5756827..87a2c9ff9a 100644 --- a/shared/HtmlReady.js +++ b/shared/HtmlReady.js @@ -166,6 +166,7 @@ function linkifyNode(child, state) {try{ const {mutate} = state if(!child.data) return if(embedYouTubeNode(child, state.links, state.images)) return + if(embedVimeoNode(child, state.links, state.images)) return const data = XMLSerializer.serializeToString(child) const content = linkify(data, state.mutate, state.hashtags, state.usertags, state.images, state.links) @@ -229,12 +230,33 @@ function embedYouTubeNode(child, links, images) {try{ } if(!id) return false - const v = DOMParser.parseFromString(`~~~ youtube:${id} ~~~`) + const v = DOMParser.parseFromString(`~~~ embed:${id} youtube ~~~`) child.parentNode.replaceChild(v, child) if(links) links.add(url) if(images) images.add('https://img.youtube.com/vi/' + id + '/0.jpg') return true +} catch(error) {console.log(error); return false}} + +function embedVimeoNode(child, links, /*images*/) {try{ + if(!child.data) return false + const data = child.data + let id + { + const m = data.match(linksRe.vimeoId) + id = m && m.length >= 2 ? m[1] : null + } + if(!id) return false; + + const url = `https://player.vimeo.com/video/${id}` + const v = DOMParser.parseFromString(`~~~ embed:${id} vimeo ~~~`) + child.parentNode.replaceChild(v, child) + if(links) links.add(url) + + // Preview image requires a callback.. http://stackoverflow.com/questions/1361149/get-img-thumbnails-from-vimeo + // if(images) images.add('https://.../vi/' + id + '/0.jpg') + + return true } catch(error) {console.log(error); return false}} function ipfsPrefix(url) {