diff --git a/app/experiments.js b/app/experiments.js new file mode 100644 index 00000000..aecee475 --- /dev/null +++ b/app/experiments.js @@ -0,0 +1,76 @@ +import hash from 'string-hash'; + +const experiments = { + '5YHCzn2CQTmBwWwTmZupBA': { + id: '5YHCzn2CQTmBwWwTmZupBA', + run: function(variant, state, emitter) { + state.experiment = { + xid: this.id, + xvar: variant + }; + // Beefy UI + if (variant === 1) { + state.config.uploadWindowStyle = 'upload-window upload-window-b'; + state.config.uploadButtonStyle = 'btn browse browse-b'; + } else { + state.config.uploadWindowStyle = 'upload-window'; + state.config.uploadButtonStyle = 'btn browse'; + } + emitter.emit('render'); + }, + eligible: function(state) { + return this.luckyNumber(state) >= 0.5; + }, + variant: function(state) { + return this.luckyNumber(state) < 0.5 ? 0 : 1; + }, + luckyNumber: function(state) { + return luckyNumber( + `${this.id}:${state.storage.get('testpilot_ga__cid')}` + ); + } + } +}; + +//Returns a number between 0 and 1 +function luckyNumber(str) { + return hash(str) / 0xffffffff; +} + +function checkExperiments(state, emitter) { + const all = Object.keys(experiments); + const id = all.find(id => experiments[id].eligible(state)); + if (id) { + const variant = experiments[id].variant(state); + state.storage.enroll(id, variant); + experiments[id].run(variant, state, emitter); + } +} + +export default function initialize(state, emitter) { + emitter.on('DOMContentLoaded', () => { + const xp = experiments[state.query.x]; + if (xp) { + xp.run(state.query.v, state, emitter); + } + }); + + if (!state.storage.get('testpilot_ga__cid')) { + // first ever visit. check again after cid is assigned. + emitter.on('DOMContentLoaded', () => { + checkExperiments(state, emitter); + }); + } else { + const enrolled = state.storage.enrolled; + enrolled.forEach(([id, variant]) => { + const xp = experiments[id]; + if (xp) { + xp.run(variant, state, emitter); + } + }); + // single experiment per session for now + if (enrolled.length === 0) { + checkExperiments(state, emitter); + } + } +} diff --git a/app/main.js b/app/main.js index 39494995..b222d129 100644 --- a/app/main.js +++ b/app/main.js @@ -7,6 +7,7 @@ import { canHasSend } from './utils'; import assets from '../common/assets'; import storage from './storage'; import metrics from './metrics'; +import experiments from './experiments'; import Raven from 'raven-js'; if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { @@ -22,6 +23,10 @@ app.use((state, emitter) => { state.translate = locale.getTranslator(); state.storage = storage; state.raven = Raven; + state.config = { + uploadWindowStyle: 'upload-window', + uploadButtonStyle: 'browse btn' + }; emitter.on('DOMContentLoaded', async () => { const ok = await canHasSend(assets.get('cryptofill.js')); if (!ok) { @@ -34,5 +39,6 @@ app.use((state, emitter) => { app.use(metrics); app.use(fileManager); app.use(dragManager); +app.use(experiments); app.mount('#page-one'); diff --git a/app/metrics.js b/app/metrics.js index 61f99971..ecb2c54d 100644 --- a/app/metrics.js +++ b/app/metrics.js @@ -15,23 +15,44 @@ const analytics = new testPilotGA({ }); let appState = null; +let experiment = null; export default function initialize(state, emitter) { appState = state; emitter.on('DOMContentLoaded', () => { addExitHandlers(); + experiment = storage.enrolled[0]; + sendEvent(category(), 'visit', { + cm5: storage.totalUploads, + cm6: storage.files.length, + cm7: storage.totalDownloads + }); //TODO restart handlers... somewhere }); } function category() { - return appState.route === '/' ? 'sender' : 'recipient'; + switch (appState.route) { + case '/': + case '/share/:id': + return 'sender'; + case '/download/:id/:key': + case '/download/:id': + case '/completed': + return 'recipient'; + default: + return 'other'; + } } function sendEvent() { + const args = Array.from(arguments); + if (experiment && args[2]) { + args[2].xid = experiment[0]; + args[2].xvar = experiment[1]; + } return ( - hasLocalStorage && - analytics.sendEvent.apply(analytics, arguments).catch(() => 0) + hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0) ); } diff --git a/app/storage.js b/app/storage.js index 75483b42..209d6237 100644 --- a/app/storage.js +++ b/app/storage.js @@ -42,7 +42,11 @@ class Storage { const k = this.engine.key(i); if (isFile(k)) { try { - fs.push(JSON.parse(this.engine.getItem(k))); + const f = JSON.parse(this.engine.getItem(k)); + if (!f.id) { + f.id = f.fileId; + } + fs.push(f); } catch (err) { // obviously you're not a golfer this.engine.removeItem(k); @@ -70,6 +74,18 @@ class Storage { set referrer(str) { this.engine.setItem('referrer', str); } + get enrolled() { + return JSON.parse(this.engine.getItem('experiments') || '[]'); + } + + enroll(id, variant) { + const enrolled = this.enrolled; + // eslint-disable-next-line no-unused-vars + if (!enrolled.find(([i, v]) => i === id)) { + enrolled.push([id, variant]); + this.engine.setItem('experiments', JSON.stringify(enrolled)); + } + } get files() { return this._files; @@ -83,6 +99,10 @@ class Storage { } } + get(id) { + return this.engine.getItem(id); + } + remove(property) { if (isFile(property)) { this._files.splice(this._files.findIndex(f => f.id === property), 1); diff --git a/app/templates/welcome.js b/app/templates/welcome.js index 27077b3c..582d3186 100644 --- a/app/templates/welcome.js +++ b/app/templates/welcome.js @@ -13,7 +13,8 @@ module.exports = function(state, emit) { 'uploadPageLearnMore' )} -