@@ -323,17 +376,21 @@ export default class UserProfile extends React.Component {
@@ -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 =
-
-
- {translate('vote')}
-
-
- {translate('witness')}
-
-
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) {