From f7be7205e7a7a9e2eb0a9e44f65a8173f16e148e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 7 Jun 2016 16:14:19 +0200 Subject: [PATCH] Remove localstorage and add notifications (#7588) * move remaining files frm /common/script/public to website/public * remove localstorage * add back noscript template and put all javascript in the footer * fixes client side tests * remove double quotes where possible * simplify jade code and add tests for buildManifest * loading page with logo and spinner * better loading screen in landscape mode * icon on top of text logo * wip: user.notifications * notifications: simpler and working code * finish implementing notifications * correct loading screen css and re-inline images * add tests for user notifications * split User model in multiple files * remove old comment about missing .catch() * correctly setup hooks and methods for User model. Cleanup localstorage * include UserNotificationsService in static page js and split loading-screen css in its own file * add cron notification and misc fixes * remove console.log * fix tests * fix multiple notifications --- common/locales/en/messages.json | 3 +- common/script/constants.js | 3 +- common/script/fns/ultimateGear.js | 4 + common/script/fns/updateStats.js | 3 + common/script/index.js | 5 + common/script/ops/index.js | 1 - common/script/ops/rebirth.js | 2 + common/script/ops/scoreTask.js | 5 +- ...lenges_challengeId_winner_winnerId.test.js | 2 + .../dataexport/GET-export_history.csv.test.js | 17 +- .../hall/PUT-hall_heores_heroId.test.js | 2 + .../user/POST-user_rebirth.test.js | 3 + test/api/v3/integration/user/PUT-user.test.js | 1 + test/api/v3/unit/libs/buildManifest.test.js | 12 + test/api/v3/unit/libs/cron.test.js | 54 +++ test/api/v3/unit/middlewares/response.js | 41 +++ test/api/v3/unit/models/user.test.js | 26 ++ test/common/fns/ultimateGear.js | 4 + test/common/fns/updateStats.test.js | 15 + test/helpers/api-unit.helper.js | 2 +- test/spec/controllers/filtersCtrlSpec.js | 1 + test/spec/services/userServicesSpec.js | 2 - website/client/css/index.styl | 1 + website/client/css/loading-screen.styl | 55 +++ .../public => website/client/js}/config.js | 23 +- .../client/js/controllers/memberModalCtrl.js | 1 + .../client/js/controllers/notificationCtrl.js | 107 ++++-- website/client/js/controllers/rootCtrl.js | 4 +- .../client/js/directives}/directives.js | 0 website/client/js/services/guideServices.js | 10 - website/client/js/services/taskServices.js | 1 + .../js/services/userNotificationsService.js | 22 ++ website/client/js/services/userServices.js | 30 +- website/client/manifest.json | 7 +- website/server/controllers/api-v3/hall.js | 2 + .../controllers/api-v3/notifications.js | 47 +++ website/server/libs/api-v3/buildManifest.js | 28 +- website/server/libs/api-v3/cron.js | 22 ++ website/server/middlewares/api-v3/response.js | 7 +- website/server/middlewares/api-v3/static.js | 1 - website/server/models/challenge.js | 6 +- website/server/models/tag.js | 2 +- website/server/models/task.js | 3 +- website/server/models/user/hooks.js | 170 +++++++++ website/server/models/user/index.js | 33 ++ website/server/models/user/methods.js | 129 +++++++ .../server/models/{user.js => user/schema.js} | 328 +----------------- website/server/models/userNotification.js | 42 +++ website/views/index.jade | 62 ++-- 49 files changed, 915 insertions(+), 436 deletions(-) create mode 100644 website/client/css/loading-screen.styl rename {common/script/public => website/client/js}/config.js (82%) rename {common/script/public => website/client/js/directives}/directives.js (100%) create mode 100644 website/client/js/services/userNotificationsService.js create mode 100644 website/server/controllers/api-v3/notifications.js create mode 100644 website/server/models/user/hooks.js create mode 100644 website/server/models/user/index.js create mode 100644 website/server/models/user/methods.js rename website/server/models/{user.js => user/schema.js} (65%) create mode 100644 website/server/models/userNotification.js diff --git a/common/locales/en/messages.json b/common/locales/en/messages.json index 1c8ce5dcd8..d597b5b5a1 100644 --- a/common/locales/en/messages.json +++ b/common/locales/en/messages.json @@ -58,5 +58,6 @@ "messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!", "messageUserOperationProtected": "path `<%= operation %>` was not saved, as it's a protected path.", - "messageUserOperationNotFound": "<%= operation %> operation not found" + "messageUserOperationNotFound": "<%= operation %> operation not found", + "messageNotificationNotFound": "Notification not found." } diff --git a/common/script/constants.js b/common/script/constants.js index 040b968f8f..d1cda98f0d 100644 --- a/common/script/constants.js +++ b/common/script/constants.js @@ -2,4 +2,5 @@ export const MAX_HEALTH = 50; export const MAX_LEVEL = 100; export const MAX_STAT_POINTS = MAX_LEVEL; export const ATTRIBUTES = ['str', 'int', 'per', 'con']; -export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; + +export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; \ No newline at end of file diff --git a/common/script/fns/ultimateGear.js b/common/script/fns/ultimateGear.js index 5bca8ff912..90f6537a55 100644 --- a/common/script/fns/ultimateGear.js +++ b/common/script/fns/ultimateGear.js @@ -12,6 +12,10 @@ module.exports = function ultimateGear (user) { }); return soFarGood && (!found || owned[found.key] === true); }, true); + + if (user.achievements.ultimateGearSets[klass] === true) { + user.addNotification('ULTIMATE_GEAR_ACHIEVEMENT'); + } } }); diff --git a/common/script/fns/updateStats.js b/common/script/fns/updateStats.js index 6061f717ab..8b9920f791 100644 --- a/common/script/fns/updateStats.js +++ b/common/script/fns/updateStats.js @@ -59,6 +59,8 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { } if (!user.flags.dropsEnabled && user.stats.lvl >= 3) { user.flags.dropsEnabled = true; + user.addNotification('DROPS_ENABLED'); + if (user.items.eggs.Wolf > 0) { user.items.eggs.Wolf++; } else { @@ -92,6 +94,7 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { } }); if (!user.flags.rebirthEnabled && (user.stats.lvl >= 50 || user.achievements.beastMaster)) { + user.addNotification('REBIRTH_ENABLED'); user.flags.rebirthEnabled = true; } }; diff --git a/common/script/index.js b/common/script/index.js index 50b01bf762..3d015ddb9a 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -242,6 +242,11 @@ api.wrap = function wrapUser (user, main = true) { user.markModified = function noopMarkModified () {}; } + // same for addNotification + if (!user.addNotification) { + user.addNotification = function noopAddNotification () {}; + } + if (main) { user.ops = { update: _.partial(importedOps.update, user), diff --git a/common/script/ops/index.js b/common/script/ops/index.js index 2e8bca246d..2ad253b2e8 100644 --- a/common/script/ops/index.js +++ b/common/script/ops/index.js @@ -48,7 +48,6 @@ import openMysteryItem from './openMysteryItem'; import scoreTask from './scoreTask'; import markPmsRead from './markPMSRead'; - module.exports = { update, sleep, diff --git a/common/script/ops/rebirth.js b/common/script/ops/rebirth.js index 54f9533fc5..57bdfc181b 100644 --- a/common/script/ops/rebirth.js +++ b/common/script/ops/rebirth.js @@ -93,6 +93,8 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) { user.achievements.rebirthLevel = lvl; } + user.addNotification('REBIRTH_ACHIEVEMENT'); + user.stats.buffs = {}; if (req.v2 === true) { diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index c366b84624..42751d7c84 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -214,7 +214,10 @@ module.exports = function scoreTask (options = {}, req = {}) { if (direction === 'up') { task.streak += 1; // Give a streak achievement when the streak is a multiple of 21 - if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + if (task.streak % 21 === 0) { + user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + user.addNotification('STREAK_ACHIEVEMENT'); + } task.completed = true; } else if (direction === 'down') { // Remove a streak achievement if streak was a multiple of 21 and the daily was undone diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js index 98dfc20926..d8a9342332 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -100,6 +100,8 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { await sleep(0.5); await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); + expect(winningUser.notifications.length).to.equal(1); + expect(winningUser.notifications[0].type).to.equal('WON_CHALLENGE'); }); it('gives winner gems as reward', async () => { diff --git a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js index 2dbc80c49d..22eaf3d99f 100644 --- a/test/api/v3/integration/dataexport/GET-export_history.csv.test.js +++ b/test/api/v3/integration/dataexport/GET-export_history.csv.test.js @@ -17,16 +17,19 @@ describe('GET /export/history.csv', () => { ]); // score all the tasks twice - await Promise.all(tasks.map(task => { - return user.post(`/tasks/${task._id}/score/up`); - })); - await Promise.all(tasks.map(task => { - return user.post(`/tasks/${task._id}/score/up`); - })); + await user.post(`/tasks/${tasks[0]._id}/score/up`); + await user.post(`/tasks/${tasks[1]._id}/score/up`); + await user.post(`/tasks/${tasks[2]._id}/score/up`); + await user.post(`/tasks/${tasks[3]._id}/score/up`); + + await user.post(`/tasks/${tasks[0]._id}/score/up`); + await user.post(`/tasks/${tasks[1]._id}/score/up`); + await user.post(`/tasks/${tasks[2]._id}/score/up`); + await user.post(`/tasks/${tasks[3]._id}/score/up`); // adding an history entry to daily 1 manually because cron didn't run yet await updateDocument('tasks', tasks[1], { - history: {value: 3.2, date: Number(new Date())}, + history: [{value: 3.2, date: Number(new Date())}], }); // get updated tasks diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js index bb724a51b4..bccf03f0c3 100644 --- a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -68,6 +68,8 @@ describe('PUT /heroes/:heroId', () => { expect(hero.contributor.level).to.equal(1); expect(hero.purchased.ads).to.equal(true); expect(hero.auth.blocked).to.equal(true); + expect(hero.notifications.length).to.equal(1); + expect(hero.notifications[0].type).to.equal('NEW_CONTRIBUTOR_LEVEL'); }); it('updates contributor level', async () => { diff --git a/test/api/v3/integration/user/POST-user_rebirth.test.js b/test/api/v3/integration/user/POST-user_rebirth.test.js index 21fbed0b8d..f418590020 100644 --- a/test/api/v3/integration/user/POST-user_rebirth.test.js +++ b/test/api/v3/integration/user/POST-user_rebirth.test.js @@ -46,6 +46,9 @@ describe('POST /user/rebirth', () => { let response = await user.post('/user/rebirth'); await user.sync(); + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('REBIRTH_ACHIEVEMENT'); + let updatedDaily = await user.get(`/tasks/${daily._id}`); let updatedReward = await user.get(`/tasks/${reward._id}`); diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index f606c95c2c..d967504fc7 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -36,6 +36,7 @@ describe('PUT /user', () => { backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'}, subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, + notifications: [{type: 123}], }; each(protectedOperations, (data, testName) => { diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index 1444738f10..c1213fd140 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -10,6 +10,18 @@ describe('Build Manifest', () => { expect(htmlCode.startsWith(' { + let htmlCode = getManifestFiles('app', 'js'); + + expect(htmlCode.indexOf(' { + let htmlCode = getManifestFiles('app', 'css'); + + expect(htmlCode.indexOf(' { expect(() => { getManifestFiles('strange name here'); diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js index d6d0866ef9..7141837f24 100644 --- a/test/api/v3/unit/libs/cron.test.js +++ b/test/api/v3/unit/libs/cron.test.js @@ -517,6 +517,60 @@ describe('cron', () => { }); }); + describe('notifications', () => { + it('adds a user notification', () => { + let mpBefore = user.stats.mp; + tasksByType.dailys[0].completed = true; + user._statsComputed.maxMP = 100; + + daysMissed = 1; + let hpBefore = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('CRON'); + expect(user.notifications[0].data).to.eql({ + hp: user.stats.hp - hpBefore, + mp: user.stats.mp - mpBefore, + }); + }); + + it('condenses multiple notifications into one', () => { + let mpBefore1 = user.stats.mp; + tasksByType.dailys[0].completed = true; + user._statsComputed.maxMP = 100; + + daysMissed = 1; + let hpBefore1 = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('CRON'); + expect(user.notifications[0].data).to.eql({ + hp: user.stats.hp - hpBefore1, + mp: user.stats.mp - mpBefore1, + }); + + let hpBefore2 = user.stats.hp; + let mpBefore2 = user.stats.mp; + + user.lastCron = moment(new Date()).subtract({days: 2}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.notifications.length).to.equal(1); + expect(user.notifications[0].type).to.equal('CRON'); + expect(user.notifications[0].data).to.eql({ + hp: user.stats.hp - hpBefore2 - (hpBefore2 - hpBefore1), + mp: user.stats.mp - mpBefore2 - (mpBefore2 - mpBefore1), + }); + }); + }); + describe('private messages', () => { let lastMessageId; diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index a24bd881ce..b3182ea87a 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -32,6 +32,7 @@ describe('response middleware', () => { expect(res.json).to.be.calledWith({ success: true, data: {field: 1}, + notifications: [], }); }); @@ -47,6 +48,7 @@ describe('response middleware', () => { success: true, data: {field: 1}, message: 'hello', + notifications: [], }); }); @@ -61,6 +63,45 @@ describe('response middleware', () => { expect(res.json).to.be.calledWith({ success: false, data: {field: 1}, + notifications: [], + }); + }); + + it('returns userV if a user is authenticated req.query.userV is passed', () => { + responseMiddleware(req, res, next); + req.query.userV = 3; + res.respond(200, {field: 1}); + + expect(res.json).to.be.calledOnce; + + expect(res.json).to.be.calledWith({ + success: true, + data: {field: 1}, + notifications: [], + userV: 0, + }); + }); + + it('returns notifications if a user is authenticated', () => { + res.locals.user.notifications.push({type: 'NEW_CONTRIBUTOR_LEVEL'}); + let notification = res.locals.user.notifications[0].toJSON(); + + responseMiddleware(req, res, next); + res.respond(200, {field: 1}); + + expect(res.json).to.be.calledOnce; + + expect(res.json).to.be.calledWith({ + success: true, + data: {field: 1}, + notifications: [ + { + type: notification.type, + id: notification.id, + createdAt: notification.createdAt, + data: {}, + }, + ], }); }); }); diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js index d7f509712c..29ac2c0c07 100644 --- a/test/api/v3/unit/models/user.test.js +++ b/test/api/v3/unit/models/user.test.js @@ -30,4 +30,30 @@ describe('User Model', () => { expect(toJSON._tmp).to.eql({ok: true}); expect(toJSON).to.not.have.keys('_nonTmp'); }); + + context('notifications', () => { + it('can add notifications with data', () => { + let user = new User(); + + user.addNotification('CRON'); + + let userToJSON = user.toJSON(); + expect(user.notifications.length).to.equal(1); + expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'createdAt']); + expect(userToJSON.notifications[0].type).to.equal('CRON'); + expect(userToJSON.notifications[0].data).to.eql({}); + }); + + it('can add notifications without data', () => { + let user = new User(); + + user.addNotification('CRON', {field: 1}); + + let userToJSON = user.toJSON(); + expect(user.notifications.length).to.equal(1); + expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'createdAt']); + expect(userToJSON.notifications[0].type).to.equal('CRON'); + expect(userToJSON.notifications[0].data).to.eql({field: 1}); + }); + }); }); diff --git a/test/common/fns/ultimateGear.js b/test/common/fns/ultimateGear.js index 242b57f3c1..f141a0dbbc 100644 --- a/test/common/fns/ultimateGear.js +++ b/test/common/fns/ultimateGear.js @@ -11,6 +11,7 @@ describe('shared.fns.ultimateGear', () => { user.achievements.ultimateGearSets.toObject = function () { return this; }; + user.addNotification = sinon.spy(); }); it('sets armoirEnabled when partial achievement already achieved', () => { @@ -31,7 +32,10 @@ describe('shared.fns.ultimateGear', () => { user.items = items; ultimateGear(user); + expect(user.flags.armoireEnabled).to.equal(true); + expect(user.addNotification).to.be.calledOnce; + expect(user.addNotification).to.be.calledWith('ULTIMATE_GEAR_ACHIEVEMENT'); }); it('does not set armoirEnabled when gear is not owned', () => { diff --git a/test/common/fns/updateStats.test.js b/test/common/fns/updateStats.test.js index cea5f6e6ca..62cc485df7 100644 --- a/test/common/fns/updateStats.test.js +++ b/test/common/fns/updateStats.test.js @@ -8,6 +8,7 @@ describe('common.fns.updateStats', () => { beforeEach(() => { user = generateUser(); + user.addNotification = sinon.spy(); }); context('No Hp', () => { @@ -109,6 +110,20 @@ describe('common.fns.updateStats', () => { expect(user.stats.points).to.eql(10); }); + it('add user notification when drops are enabled', () => { + user.stats.lvl = 3; + updateStats(user, { }); + expect(user.addNotification).to.be.calledOnce; + expect(user.addNotification).to.be.calledWith('DROPS_ENABLED'); + }); + + it('add user notification when rebirth is enabled', () => { + user.stats.lvl = 51; + updateStats(user, { }); + expect(user.addNotification).to.be.calledTwice; // once is for drops enabled + expect(user.addNotification).to.be.calledWith('REBIRTH_ENABLED'); + }); + context('assigns flags.levelDrops', () => { it('for atom1', () => { user.stats.lvl = 16; diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index ec20ae763e..1c567928c6 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -16,7 +16,7 @@ afterEach((done) => { export { sleep } from './sleep'; export function generateUser (options = {}) { - return new User(options).toObject(); + return new User(options); } export function generateGroup (options = {}) { diff --git a/test/spec/controllers/filtersCtrlSpec.js b/test/spec/controllers/filtersCtrlSpec.js index 54126ca60b..4ebf37e842 100644 --- a/test/spec/controllers/filtersCtrlSpec.js +++ b/test/spec/controllers/filtersCtrlSpec.js @@ -9,6 +9,7 @@ describe('Filters Controller', function() { scope = $rootScope.$new(); // user.filters = {}; User.setUser(user); + User.user.filters = {}; userService = User; $controller('FiltersCtrl', {$scope: scope, User: User}); })); diff --git a/test/spec/services/userServicesSpec.js b/test/spec/services/userServicesSpec.js index 7bb5b7aac9..1e25af358a 100644 --- a/test/spec/services/userServicesSpec.js +++ b/test/spec/services/userServicesSpec.js @@ -31,9 +31,7 @@ describe('userServices', function() { it('saves user data to local storage', function(){ user.save(); var settings = JSON.parse(localStorage[STORAGE_SETTINGS_ID]); - var user_id = JSON.parse(localStorage[STORAGE_USER_ID]); expect(settings).to.eql(user.settings); - expect(user_id).to.eql(user.user); }); xit('alerts when not authenticated', function(){ diff --git a/website/client/css/index.styl b/website/client/css/index.styl index 2ea7aae839..c9bea9f337 100644 --- a/website/client/css/index.styl +++ b/website/client/css/index.styl @@ -30,6 +30,7 @@ @import "./menu.styl" @import "./options.styl" @import "./no-script.styl" +@import "./loading-screen.styl" html,body,p,h1,ul,li,table,tr,th,td margin: 0 diff --git a/website/client/css/loading-screen.styl b/website/client/css/loading-screen.styl new file mode 100644 index 0000000000..b2c4212ab3 --- /dev/null +++ b/website/client/css/loading-screen.styl @@ -0,0 +1,55 @@ +#loadingScreen + z-index: 9999 + width: 100% + height: 100% + padding-top: 150px + +@media (max-device-width: 768px), (orientation: landscape) + #loadingScreen + padding-top: 75px + +#loadingScreen img + display: block + margin: 0 auto + width: 90% + +#loadingScreen .loading-logo-icon + max-width: 87.5px + margin-bottom: 15px + +#loadingScreen .loading-logo-text + max-width: 282.5px + +.loading-spinner + margin: 100px auto 0 + width: 105px + padding-left: 5px + text-align: center + margin-top: 20px + +.loading-spinner > div + width: 16px + height: 16px + background-color: #432476 + border-radius: 100% + display: inline-block + animation: sk-bouncedelay 1.7s infinite ease-in-out both + margin-right: 5px + +.loading-spinner .spinner__item1 + animation-delay: -0.60s + +.loading-spinner .spinner__item2 + animation-delay: -0.40s + +.loading-spinner .spinner__item3 + animation-delay: -0.20s + +@keyframes sk-bouncedelay + 0%, 80%, 100% { + transform: scale(0) + opacity: 0 + } 40% { + transform: scale(1.0) + opacity: 1 + } diff --git a/common/script/public/config.js b/website/client/js/config.js similarity index 82% rename from common/script/public/config.js rename to website/client/js/config.js index 4456909ef4..dc08f1a15a 100644 --- a/common/script/public/config.js +++ b/website/client/js/config.js @@ -10,9 +10,9 @@ angular.module('habitrpg') // If it was, sync function verifyUserUpdated (response) { var isApiCall = response.config.url.indexOf('api/v3') !== -1; - var isUserAvailable = $rootScope.User && $rootScope.User.user && $rootScope.User.user._wrapped === true; + var isUserAvailable = $rootScope.appLoaded === true; var hasUserV = response.data && response.data.userV; - var isNotSync = response.config.url.indexOf('/api/v3/user') !== 0; + var isNotSync = response.config.url.indexOf('/api/v3/user') !== 0 || response.config.method !== 'GET'; if (isApiCall && isUserAvailable && hasUserV) { var oldUserV = $rootScope.User.user._v; @@ -25,6 +25,24 @@ angular.module('habitrpg') } } + function verifyNewNotifications (response) { + // Ignore CRON notifications for manual syncs + var isUserLoaded = $rootScope.appLoaded === true; + + if (response && response.data && response.data.notifications && response.data.notifications.length > 0) { + $rootScope.userNotifications = response.data.notifications.filter(function (notification) { + if (isUserLoaded && notification.type === 'CRON') { + // If the user is already loaded, do not show the notification, syncing will show it + // (the user will be synced automatically) + $rootScope.User.readNotification(notification.id); + return false; + } + + return true; + }); + } + } + return { request: function (config) { var url = config.url; @@ -43,6 +61,7 @@ angular.module('habitrpg') }, response: function(response) { verifyUserUpdated(response); + verifyNewNotifications(response); return response; }, responseError: function(response) { diff --git a/website/client/js/controllers/memberModalCtrl.js b/website/client/js/controllers/memberModalCtrl.js index 6e8b96d0ae..93aa4dc8fa 100644 --- a/website/client/js/controllers/memberModalCtrl.js +++ b/website/client/js/controllers/memberModalCtrl.js @@ -5,6 +5,7 @@ habitrpg function($scope, $rootScope, Members, Shared, $http, Notification, Groups, Chat, $controller, Stats) { $controller('RootCtrl', {$scope: $scope}); + $rootScope.appLoaded = true; $scope.timestamp = function(timestamp){ return moment(timestamp).format($rootScope.User.user.preferences.dateFormat.toUpperCase()); diff --git a/website/client/js/controllers/notificationCtrl.js b/website/client/js/controllers/notificationCtrl.js index 738af5bd8a..397ffe0146 100644 --- a/website/client/js/controllers/notificationCtrl.js +++ b/website/client/js/controllers/notificationCtrl.js @@ -23,17 +23,6 @@ habitrpg.controller('NotificationCtrl', Notification.exp(after - before); }); - $rootScope.$watch('user.achievements', function(){ - $rootScope.playSound('Achievement_Unlocked'); - }, true); - - $rootScope.$watch('user.achievements.challenges.length', function(after, before) { - if (after === before) return; - if (after > before) { - $rootScope.openModal('wonChallenge', {controller: 'UserCtrl', size: 'sm'}); - } - }); - $rootScope.$watch('user.stats.gp', function(after, before) { if (after == before) return; if (User.user.stats.lvl == 0) return; @@ -82,35 +71,89 @@ habitrpg.controller('NotificationCtrl', } }); - $rootScope.$watch('user.achievements.streak', function(after, before){ - if(before == undefined || after <= before) return; - Notification.streak(User.user.achievements.streak); - $rootScope.playSound('Achievement_Unlocked'); - if (!User.user.preferences.suppressModals.streak) { - $rootScope.openModal('achievements/streak', {controller:'UserCtrl'}); - } + // Avoid showing the same notiication more than once + var lastShownNotifications = []; + + function handleUserNotifications (after) { + if (!after || after.length === 0) return; + + after.forEach(function (notification) { + if (lastShownNotifications.indexOf(notification.id) !== -1) { + return; + } + + lastShownNotifications.push(notification.id); + if (lastShownNotifications.length > 10) { + lastShownNotifications.splice(0, 9); + } + + var markAsRead = true; + + switch (notification.type) { + case 'DROPS_ENABLED': + $rootScope.openModal('dropsEnabled'); + break; + case 'REBIRTH_ENABLED': + $rootScope.openModal('rebirthEnabled'); + break; + case 'WON_CHALLENGE': + $rootScope.openModal('wonChallenge', {controller: 'UserCtrl', size: 'sm'}); + break; + case 'STREAK_ACHIEVEMENT': + Notification.streak(User.user.achievements.streak); + $rootScope.playSound('Achievement_Unlocked'); + if (!User.user.preferences.suppressModals.streak) { + $rootScope.openModal('achievements/streak', {controller:'UserCtrl'}); + } + break; + case 'ULTIMATE_GEAR_ACHIEVEMENT': + $rootScope.openModal('achievements/ultimateGear', {controller:'UserCtrl'}); + break; + case 'REBIRTH_ACHIEVEMENT': + $rootScope.openModal('achievements/rebirth', {controller:'UserCtrl', size: 'sm'}); + break; + case 'NEW_CONTRIBUTOR_LEVEL': + $rootScope.openModal('achievements/contributor',{controller:'UserCtrl'}); + break; + case 'CRON': + if (notification.data) { + if (notification.data.hp) Notification.hp(notification.data.hp, 'hp'); + if (notification.data.mp) Notification.mp(notification.data.mp); + } + break; + default: + markAsRead = false; // If the notification is not implemented, skip it + break; + } + + if (markAsRead) User.readNotification(notification.id); + }); + + User.user.notifications = []; // reset the notifications + } + + // Since we don't use localStorage anymore, notifications for achievements and new contributor levels + // are now stored user.notifications. + $rootScope.$watchCollection('userNotifications', function (after) { + if (!User.user._wrapped) return; + handleUserNotifications(after); }); - $rootScope.$watch('user.achievements.ultimateGearSets', function(after, before){ - if (_.isEqual(after,before) || !_.contains(User.user.achievements.ultimateGearSets, true)) return; - $rootScope.openModal('achievements/ultimateGear', {controller:'UserCtrl'}); + var handleUserNotificationsOnFirstSync = _.once(function () { + handleUserNotifications($rootScope.userNotifications); + }); + $rootScope.$on('userUpdated', handleUserNotificationsOnFirstSync); + + // TODO what about this? + $rootScope.$watch('user.achievements', function(){ + $rootScope.playSound('Achievement_Unlocked'); }, true); $rootScope.$watch('user.flags.armoireEmpty', function(after,before){ - if (before == undefined || after == before || after == false) return; + if (after == before || after == false) return; $rootScope.openModal('armoireEmpty'); }); - $rootScope.$watch('user.achievements.rebirths', function(after, before){ - if(after === before) return; - $rootScope.openModal('achievements/rebirth', {controller:'UserCtrl', size: 'sm'}); - }); - - $rootScope.$watch('user.contributor.level', function(after, before){ - if (after === before || after < before || after == null) return; - $rootScope.openModal('achievements/contributor',{controller:'UserCtrl'}); - }); - // Completed quest modal $scope.$watch('user.party.quest.completed', function(after, before){ if (!after) return; diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index b4fee4076a..ad3827b6f0 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -25,6 +25,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } }); + $rootScope.appLoaded = false; // also used to indicate when the user is fully loaded $rootScope.TAVERN_ID = TAVERN_ID; $rootScope.User = User; $rootScope.user = user; @@ -39,7 +40,8 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.Groups = Groups; $rootScope.toJson = angular.toJson; $rootScope.Payments = Payments; - + $rootScope.userNotifications = []; + // Angular UI Router $rootScope.$state = $state; $rootScope.$stateParams = $stateParams; diff --git a/common/script/public/directives.js b/website/client/js/directives/directives.js similarity index 100% rename from common/script/public/directives.js rename to website/client/js/directives/directives.js diff --git a/website/client/js/services/guideServices.js b/website/client/js/services/guideServices.js index 7f3ce9e499..8df1cbb679 100644 --- a/website/client/js/services/guideServices.js +++ b/website/client/js/services/guideServices.js @@ -289,16 +289,6 @@ function($rootScope, User, $timeout, $state, Analytics) { case 'options.inventory.equipment': return goto('equipment', 0); } }); - $rootScope.$watch('user.flags.dropsEnabled', function(after, before) { - if (alreadyShown(before,after)) return; - var eggs = User.user.items.eggs || {}; - if (!eggs) eggs['Wolf'] = 1; // This is also set on the server - $rootScope.openModal('dropsEnabled'); - }); - $rootScope.$watch('user.flags.rebirthEnabled', function(after, before) { - if (alreadyShown(before, after)) return; - $rootScope.openModal('rebirthEnabled'); - }); }); var Guide = { diff --git a/website/client/js/services/taskServices.js b/website/client/js/services/taskServices.js index 7a99cd748e..7396f9f749 100644 --- a/website/client/js/services/taskServices.js +++ b/website/client/js/services/taskServices.js @@ -14,6 +14,7 @@ angular.module('habitrpg') return $http({ method: 'GET', url: url, + ignoreLoadingBar: $rootScope.appLoaded !== true, }); }; diff --git a/website/client/js/services/userNotificationsService.js b/website/client/js/services/userNotificationsService.js new file mode 100644 index 0000000000..720fe4bccb --- /dev/null +++ b/website/client/js/services/userNotificationsService.js @@ -0,0 +1,22 @@ +'use strict'; + +angular.module('habitrpg') +.factory('UserNotifications', ['$http', + function userNotificationsFactory($http) { + + var lastRead; // keep track of last notification ID to avoid reding it twice + + function readNotification (notificationId) { + if (lastRead === notificationId) return; + lastRead = notificationId; + + return $http({ + method: 'POST', + url: 'api/v3/notifications/' + notificationId + '/read', + }); + }; + + return { + readNotification: readNotification, + }; + }]); diff --git a/website/client/js/services/userServices.js b/website/client/js/services/userServices.js index e0282035c6..c6ea3e7286 100644 --- a/website/client/js/services/userServices.js +++ b/website/client/js/services/userServices.js @@ -14,8 +14,8 @@ angular.module('habitrpg') /** * Services that persists and retrieves user from localStorage. */ - .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', 'Tags', 'Content', - function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks, Tags, Content) { + .factory('User', ['$rootScope', '$http', '$location', '$window', 'STORAGE_USER_ID', 'STORAGE_SETTINGS_ID', 'Notification', 'ApiUrl', 'Tasks', 'Tags', 'Content', 'UserNotifications', + function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks, Tags, Content, UserNotifications) { var authenticated = false; var defaultSettings = { auth: { apiId: '', apiToken: ''}, @@ -38,11 +38,6 @@ angular.module('habitrpg') //first we populate user with schema user.apiToken = user._id = ''; // we use id / apitoken to determine if registered - //than we try to load localStorage - if (localStorage.getItem(STORAGE_USER_ID)) { - _.extend(user, JSON.parse(localStorage.getItem(STORAGE_USER_ID))); - } - user._wrapped = false; function syncUserTasks (tasks) { @@ -78,6 +73,7 @@ angular.module('habitrpg') return $http({ method: "GET", url: '/api/v3/user/', + ignoreLoadingBar: $rootScope.appLoaded !== true, }) .then(function (response) { if (response.data.message) Notification.text(response.data.message); @@ -108,14 +104,14 @@ angular.module('habitrpg') .then(function (response) { var tasks = response.data.data; syncUserTasks(tasks); - save(); $rootScope.$emit('userSynced'); + $rootScope.appLoaded = true; }); } var save = function () { - localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user)); localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); + localStorage.removeItem(STORAGE_USER_ID); // TODO remember to remove once it's been live for a few days }; function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) { @@ -167,8 +163,6 @@ angular.module('habitrpg') var text = Content.gear.flat[openedItem.key].text(); Notification.drop(env.t('messageDropMysteryItem', {dropText: text}), openedItem); } - - save(); }) } @@ -214,7 +208,6 @@ angular.module('habitrpg') } else { user.ops.addTask(data); } - save(); Tasks.createUserTasks(data.body); }, @@ -225,7 +218,6 @@ angular.module('habitrpg') Notification.text(err.message); return; } - save(); Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { var tmp = res.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses @@ -284,37 +276,35 @@ angular.module('habitrpg') sortTask: function (data) { user.ops.sortTask(data); - save(); Tasks.moveTask(data.params.id, data.query.to); }, updateTask: function (task, data) { $window.habitrpgShared.ops.updateTask(task, data); - save(); Tasks.updateTask(task._id, data.body); }, deleteTask: function (data) { user.ops.deleteTask(data); - save(); Tasks.deleteTask(data.params.id); }, clearCompleted: function () { user.ops.clearCompleted(user.todos); - save(); Tasks.clearCompletedTodos(); }, + readNotification: function (notificationId) { + UserNotifications.readNotification(notificationId); + }, + addTag: function(data) { user.ops.addTag(data); - save(); Tags.createTag(data.body); }, updateTag: function(data) { user.ops.updateTag(data); - save(); Tags.updateTag(data.params.id, data.body); }, @@ -326,7 +316,6 @@ angular.module('habitrpg') deleteTag: function(data) { user.ops.deleteTag(data); - save(); Tags.deleteTag(data.params.id); }, @@ -495,7 +484,6 @@ angular.module('habitrpg') data: updates, }) .then(function () { - save(); $rootScope.$emit('userSynced'); }) }, diff --git a/website/client/manifest.json b/website/client/manifest.json index fb59224a34..f9aa63bbf5 100644 --- a/website/client/manifest.json +++ b/website/client/manifest.json @@ -38,11 +38,11 @@ "js/env.js", "js/app.js", - "common/script/public/config.js", + "js/config.js", "js/services/sharedServices.js", "js/services/notificationServices.js", - "common/script/public/directives.js", + "js/directives/directives.js", "js/services/analyticsServices.js", "js/services/groupServices.js", "js/services/chatServices.js", @@ -55,6 +55,7 @@ "js/services/questServices.js", "js/services/socialServices.js", "js/services/statServices.js", + "js/services/userNotificationsService.js", "js/services/userServices.js", "js/services/hallServices.js", @@ -131,6 +132,7 @@ "js/static.js", "js/services/analyticsServices.js", "js/services/notificationServices.js", + "js/services/userNotificationsService.js", "js/services/userServices.js", "js/services/sharedServices.js", "js/services/socialServices.js", @@ -173,6 +175,7 @@ "js/services/statServices.js", "js/services/taskServices.js", "js/services/tagsServices.js", + "js/services/userNotificationsService.js", "js/services/userServices.js", "js/services/memberServices.js", "js/controllers/authCtrl.js", diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index 077b1ef74b..ba3f8d14fd 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -154,6 +154,8 @@ api.updateHero = { tierDiff--; newTier--; // give them gems for the next tier down if they weren't aready that tier } + + hero.addNotification('NEW_CONTRIBUTOR_LEVEL'); } if (updateData.contributor) _.assign(hero.contributor, updateData.contributor); diff --git a/website/server/controllers/api-v3/notifications.js b/website/server/controllers/api-v3/notifications.js new file mode 100644 index 0000000000..7130b1bc56 --- /dev/null +++ b/website/server/controllers/api-v3/notifications.js @@ -0,0 +1,47 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import _ from 'lodash'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @apiIgnore Not yet part of the public API + * @api {post} /api/v3/notifications/:notificationId/read Mark one notification as read + * @apiVersion 3.0.0 + * @apiName ReadNotification + * @apiGroup Notification + * + * @apiParam {UUID} notificationId + * + * @apiSuccess {Object} data user.notifications + */ +api.readNotification = { + method: 'POST', + url: '/notifications/:notificationId/read', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('notificationId', res.t('notificationIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let index = _.findIndex(user.notifications, { + id: req.params.notificationId, + }); + + if (index === -1) { + throw new NotFound(res.t('messageNotificationNotFound')); + } + + user.notifications.splice(index, 1); + await user.save(); + + res.respond(200, user.notifications); + }, +}; + +module.exports = api; diff --git a/website/server/libs/api-v3/buildManifest.js b/website/server/libs/api-v3/buildManifest.js index 55db474354..2945ef151b 100644 --- a/website/server/libs/api-v3/buildManifest.js +++ b/website/server/libs/api-v3/buildManifest.js @@ -39,7 +39,7 @@ export function getBuildUrl (url) { return `/${buildFiles[url] || url}`; } -export function getManifestFiles (page) { +export function getManifestFiles (page, type) { let files = manifestFiles[page]; if (!files) throw new Error(`Page "${page}" not found!`); @@ -47,15 +47,25 @@ export function getManifestFiles (page) { let htmlCode = ''; if (IS_PROD) { - htmlCode += ``; // eslint-disable-line prefer-template - htmlCode += ``; // eslint-disable-line prefer-template + if (type !== 'js') { + htmlCode += ``; // eslint-disable-line prefer-template + } + + if (type !== 'css') { + htmlCode += ``; // eslint-disable-line prefer-template + } } else { - files.css.forEach((file) => { - htmlCode += ``; - }); - files.js.forEach((file) => { - htmlCode += ``; - }); + if (type !== 'js') { + files.css.forEach((file) => { + htmlCode += ``; + }); + } + + if (type !== 'css') { + files.js.forEach((file) => { + htmlCode += ``; + }); + } } return htmlCode; diff --git a/website/server/libs/api-v3/cron.js b/website/server/libs/api-v3/cron.js index 765b4307fa..de65168fea 100644 --- a/website/server/libs/api-v3/cron.js +++ b/website/server/libs/api-v3/cron.js @@ -111,6 +111,9 @@ function performSleepTasks (user, tasksByType, now) { export function cron (options = {}) { let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; + // Record pre-cron values of HP and MP to show notifications later + let beforeCronStats = _.pick(user.stats, ['hp', 'mp']); + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; // User is only allowed a certain number of drops a day. This resets the count. if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; @@ -279,6 +282,25 @@ export function cron (options = {}) { let _progress = _.cloneDeep(progress); _.merge(progress, {down: 0, up: 0, collectedItems: 0}); + // Send notification for changes in HP and MP + + // First remove a possible previous cron notification + // we don't want to flood the users with many cron notifications at once + + let oldCronNotif = user.notifications.toObject().find((notif, index) => { + if (notif.type === 'CRON') { + user.notifications.splice(index, 1); + return true; + } else { + return false; + } + }); + + user.addNotification('CRON', { + hp: user.stats.hp - beforeCronStats.hp - (oldCronNotif ? oldCronNotif.data.hp : 0), + mp: user.stats.mp - beforeCronStats.mp - (oldCronNotif ? oldCronNotif.data.mp : 0), + }); + // TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn // let numberOfPMs = Object.keys(user.inbox.messages).length; // if (numberOfPMs > maxPMs) { diff --git a/website/server/middlewares/api-v3/response.js b/website/server/middlewares/api-v3/response.js index e00d691fb2..944c64c046 100644 --- a/website/server/middlewares/api-v3/response.js +++ b/website/server/middlewares/api-v3/response.js @@ -14,8 +14,11 @@ module.exports = function responseHandler (req, res, next) { // sends back the current user._v in the response so that the client // can verify if it's the most up to date data. // Considered part of the private API for now and not officially supported - if (user && req.query.userV) { - response.userV = user._v; + if (user) { + response.notifications = user.notifications.map(notification => notification.toJSON()); + if (req.query.userV) { + response.userV = user._v; + } } res.status(status).json(response); diff --git a/website/server/middlewares/api-v3/static.js b/website/server/middlewares/api-v3/static.js index 19c1c9025c..95736fcaec 100644 --- a/website/server/middlewares/api-v3/static.js +++ b/website/server/middlewares/api-v3/static.js @@ -12,7 +12,6 @@ module.exports = function staticMiddleware (expressApp) { expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE })); expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE })); - expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE })); expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE })); expressApp.use(express.static(PUBLIC_DIR)); }; diff --git a/website/server/models/challenge.js b/website/server/models/challenge.js index 511c969429..b9f7e87e6a 100644 --- a/website/server/models/challenge.js +++ b/website/server/models/challenge.js @@ -15,7 +15,7 @@ import { sendTxn as txnEmail } from '../libs/api-v3/email'; import sendPushNotification from '../libs/api-v3/pushNotifications'; import cwait from 'cwait'; -let Schema = mongoose.Schema; +const Schema = mongoose.Schema; let schema = new Schema({ name: {type: String, required: true}, @@ -286,7 +286,11 @@ schema.methods.closeChal = async function closeChal (broken = {}) { if (winner) { winner.achievements.challenges.push(challenge.name); winner.balance += challenge.prize / 4; + + winner.addNotification('WON_CHALLENGE'); + let savedWinner = await winner.save(); + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { txnEmail(savedWinner, 'won-challenge', [ {name: 'CHALLENGE_NAME', content: challenge.name}, diff --git a/website/server/models/tag.js b/website/server/models/tag.js index f201541540..77938ab7d9 100644 --- a/website/server/models/tag.js +++ b/website/server/models/tag.js @@ -3,7 +3,7 @@ import baseModel from '../libs/api-v3/baseModel'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; -let Schema = mongoose.Schema; +const Schema = mongoose.Schema; export let schema = new Schema({ id: { diff --git a/website/server/models/task.js b/website/server/models/task.js index 0664fab855..cb66aefeff 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -6,7 +6,8 @@ import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import { preenHistory } from '../libs/api-v3/preening'; -let Schema = mongoose.Schema; +const Schema = mongoose.Schema; + let discriminatorOptions = { discriminatorKey: 'type', // the key that distinguishes task types }; diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js new file mode 100644 index 0000000000..abdddb8707 --- /dev/null +++ b/website/server/models/user/hooks.js @@ -0,0 +1,170 @@ +import shared from '../../../../common'; +import _ from 'lodash'; +import moment from 'moment'; +import * as Tasks from '../task'; +import Bluebird from 'bluebird'; +import baseModel from '../../libs/api-v3/baseModel'; + +import schema from './schema'; + +schema.plugin(baseModel, { + // noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) + noSet: [], + private: ['auth.local.hashed_password', 'auth.local.salt', '_cronSignature'], + toJSONTransform: function userToJSON (plainObj, originalDoc) { + plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs + + return plainObj; + }, +}); + +schema.post('init', function postInitUser (doc) { + shared.wrap(doc); +}); + +function _populateDefaultTasks (user, taskTypes) { + let tagsI = taskTypes.indexOf('tag'); + + if (tagsI !== -1) { + user.tags = _.map(shared.content.userDefaults.tags, (tag) => { + let newTag = _.cloneDeep(tag); + + // tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTag.id = shared.uuid(); + // Render tag's name in user's language + newTag.name = newTag.name(user.preferences.language); + return newTag; + }); + } + + let tasksToCreate = []; + + if (tagsI !== -1) { + taskTypes = _.clone(taskTypes); + taskTypes.splice(tagsI, 1); + } + + _.each(taskTypes, (taskType) => { + let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => { + let newTask = new Tasks[taskType](taskDefaults); + + newTask.userId = user._id; + newTask.text = taskDefaults.text(user.preferences.language); + if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language); + if (taskDefaults.checklist) { + newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => { + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); + } + + return newTask.save(); + }); + + tasksToCreate.push(...tasksOfType); + }); + + return Bluebird.all(tasksToCreate) + .then((tasksCreated) => { + _.each(tasksCreated, (task) => { + user.tasksOrder[`${task.type}s`].push(task._id); + }); + }); +} + +function _populateDefaultsForNewUser (user) { + let taskTypes; + let iterableFlags = user.flags.toObject(); + + if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') { + taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag']; + + _.each(iterableFlags.tutorial.common, (val, section) => { + user.flags.tutorial.common[section] = true; + }); + } else { + taskTypes = ['todo', 'tag']; + user.flags.showTour = false; + + _.each(iterableFlags.tour, (val, section) => { + user.flags.tour[section] = -2; + }); + } + + return _populateDefaultTasks(user, taskTypes); +} + +function _setProfileName (user) { + let fb = user.auth.facebook; + + let localUsername = user.auth.local && user.auth.local.username; + let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`); + let anonymous = 'Anonymous'; + + return localUsername || facebookUsername || anonymous; +} + +schema.pre('save', true, function preSaveUser (next, done) { + next(); + + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + this.profile.name = _setProfileName(this); + } + + // Determines if Beast Master should be awarded + let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); + + if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true; + } + + // Determines if Mount Master should be awarded + let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); + + if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true; + } + + // Determines if Triad Bingo should be awarded + + let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); + let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; + + if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // Enable weekly recap emails for old users who sign in + if (this.flags.lastWeeklyRecapDiscriminator) { + // Enable weekly recap emails in 24 hours + this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); + // Unset the field so this is run only once + this.flags.lastWeeklyRecapDiscriminator = undefined; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + // our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + // Populate new users with default content + if (this.isNew) { + _populateDefaultsForNewUser(this) + .then(() => done()) + .catch(done); + } else { + done(); + } +}); + +schema.pre('update', function preUpdateUser () { + this.update({}, {$inc: {_v: 1}}); +}); diff --git a/website/server/models/user/index.js b/website/server/models/user/index.js new file mode 100644 index 0000000000..c220339f72 --- /dev/null +++ b/website/server/models/user/index.js @@ -0,0 +1,33 @@ +import mongoose from 'mongoose'; + +import schema from './schema'; + +require('./hooks'); +require('./methods'); + +// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) +export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt + preferences.chair preferences.costume preferences.sleep preferences.background profile stats + achievements party backer contributor auth.timestamps items`; + +// The minimum amount of data needed when populating multiple users +export let nameFields = 'profile.name'; + +export { schema }; + +export let model = mongoose.model('User', schema); + +// Initially export an empty object so external requires will get +// the right object by reference when it's defined later +// Otherwise it would remain undefined if requested before the query executes +export let mods = []; + +mongoose.model('User') + .find({'contributor.admin': true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec() + .then((foundMods) => { + // Using push to maintain the reference to mods + mods.push(...foundMods); + }); diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js new file mode 100644 index 0000000000..0b6a0469f3 --- /dev/null +++ b/website/server/models/user/methods.js @@ -0,0 +1,129 @@ +import shared from '../../../../common'; +import _ from 'lodash'; +import * as Tasks from '../task'; +import Bluebird from 'bluebird'; +import { + chatDefaults, + TAVERN_ID, +} from '../group'; +import { defaults } from 'lodash'; + +import schema from './schema'; + +schema.methods.isSubscribed = function isSubscribed () { + return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion +}; + +// Get an array of groups ids the user is member of +schema.methods.getGroups = function getUserGroups () { + let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original + if (this.party._id) userGroups.push(this.party._id); + userGroups.push(TAVERN_ID); + return userGroups; +}; + +schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) { + let sender = this; + + shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender)); + userToReceiveMessage.inbox.newMessages++; + userToReceiveMessage._v++; + userToReceiveMessage.markModified('inbox.messages'); + + shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage))); + sender.markModified('inbox.messages'); + + let promises = [userToReceiveMessage.save(), sender.save()]; + await Bluebird.all(promises); +}; + +schema.methods.addNotification = function addUserNotification (type, data = {}) { + this.notifications.push({ + type, + data, + }); +}; + +// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) +// These will be removed once API v2 is discontinued + +// Get all the tasks belonging to a user, +schema.methods.getTasks = function getUserTasks () { + let args = Array.from(arguments); + let cb; + let type; + + if (args.length === 1) { + cb = args[0]; + } else { + type = args[0]; + cb = args[1]; + } + + let query = { + userId: this._id, + }; + + if (type) query.type = type; + + Tasks.Task.find(query, cb); +}; + +// Given user and an array of tasks, return an API compatible user + tasks obj +schema.methods.addTasksToUser = function addTasksToUser (tasks) { + let obj = this.toJSON(); + + obj.id = obj._id; + obj.filters = {}; + + obj.tags = obj.tags.map(tag => { + return { + id: tag.id, + name: tag.name, + challenge: tag.challenge, + }; + }); + + let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it + + obj.habits = []; + obj.dailys = []; + obj.todos = []; + obj.rewards = []; + + obj.tasksOrder = undefined; + let unordered = []; + + tasks.forEach((task) => { + // We want to push the task at the same position where it's stored in tasksOrder + let pos = tasksOrder[`${task.type}s`].indexOf(task._id); + if (pos === -1) { // Should never happen, it means the lists got out of sync + unordered.push(task.toJSONV2()); + } else { + obj[`${task.type}s`][pos] = task.toJSONV2(); + } + }); + + // Reconcile unordered items + unordered.forEach((task) => { + obj[`${task.type}s`].push(task); + }); + + // Remove null values that can be created when inserting tasks at an index > length + ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { + obj[type] = _.compact(obj[type]); + }); + + return obj; +}; + +// Return the data maintaining backward compatibility +schema.methods.getTransformedData = function getTransformedData (cb) { + let self = this; + this.getTasks((err, tasks) => { + if (err) return cb(err); + cb(null, self.addTasksToUser(tasks)); + }); +}; + +// END of API v2 methods \ No newline at end of file diff --git a/website/server/models/user.js b/website/server/models/user/schema.js similarity index 65% rename from website/server/models/user.js rename to website/server/models/user/schema.js index ba3377e865..96d2a19904 100644 --- a/website/server/models/user.js +++ b/website/server/models/user/schema.js @@ -1,22 +1,16 @@ import mongoose from 'mongoose'; -import shared from '../../../common'; +import shared from '../../../../common'; import _ from 'lodash'; import validator from 'validator'; -import moment from 'moment'; -import * as Tasks from './task'; -import Bluebird from 'bluebird'; -import { schema as TagSchema } from './tag'; -import baseModel from '../libs/api-v3/baseModel'; +import { schema as TagSchema } from '../tag'; import { - chatDefaults, - TAVERN_ID, -} from './group'; -import { defaults } from 'lodash'; + schema as UserNotificationSchema, +} from '../userNotification'; -let Schema = mongoose.Schema; +const Schema = mongoose.Schema; // User schema definition -export let schema = new Schema({ +let schema = new Schema({ apiToken: { type: String, default: shared.uuid, @@ -495,6 +489,7 @@ export let schema = new Schema({ }, }, + notifications: [UserNotificationSchema], tags: [TagSchema], inbox: { @@ -514,312 +509,13 @@ export let schema = new Schema({ extra: {type: Schema.Types.Mixed, default: () => { return {}; }}, - pushDevices: { - type: [{ - regId: {type: String}, - type: {type: String}, - }], - default: () => [], - }, + pushDevices: [{ + regId: {type: String}, + type: {type: String}, + }], }, { strict: true, minimize: false, // So empty objects are returned }); -schema.plugin(baseModel, { - // noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...) - noSet: [], - private: ['auth.local.hashed_password', 'auth.local.salt', '_cronSignature'], - toJSONTransform: function userToJSON (plainObj, originalDoc) { - // plainObj.filters = {}; // TODO Not saved, remove? - plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs - - return plainObj; - }, -}); - -// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) -export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt - preferences.chair preferences.costume preferences.sleep preferences.background profile stats - achievements party backer contributor auth.timestamps items`; - -// The minimum amount of data needed when populating multiple users -export let nameFields = 'profile.name'; - -schema.post('init', function postInitUser (doc) { - shared.wrap(doc); -}); - -function _populateDefaultTasks (user, taskTypes) { - let tagsI = taskTypes.indexOf('tag'); - - if (tagsI !== -1) { - user.tags = _.map(shared.content.userDefaults.tags, (tag) => { - let newTag = _.cloneDeep(tag); - - // tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTag.id = shared.uuid(); - // Render tag's name in user's language - newTag.name = newTag.name(user.preferences.language); - return newTag; - }); - } - - let tasksToCreate = []; - - if (tagsI !== -1) { - taskTypes = _.clone(taskTypes); - taskTypes.splice(tagsI, 1); - } - - _.each(taskTypes, (taskType) => { - let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => { - let newTask = new Tasks[taskType](taskDefaults); - - newTask.userId = user._id; - newTask.text = taskDefaults.text(user.preferences.language); - if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language); - if (taskDefaults.checklist) { - newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => { - checklistItem.text = checklistItem.text(user.preferences.language); - return checklistItem; - }); - } - - return newTask.save(); - }); - - tasksToCreate.push(...tasksOfType); - }); - - return Bluebird.all(tasksToCreate) - .then((tasksCreated) => { - _.each(tasksCreated, (task) => { - user.tasksOrder[`${task.type}s`].push(task._id); - }); - }); -} - -function _populateDefaultsForNewUser (user) { - let taskTypes; - let iterableFlags = user.flags.toObject(); - - if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') { - taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag']; - - _.each(iterableFlags.tutorial.common, (val, section) => { - user.flags.tutorial.common[section] = true; - }); - } else { - taskTypes = ['todo', 'tag']; - user.flags.showTour = false; - - _.each(iterableFlags.tour, (val, section) => { - user.flags.tour[section] = -2; - }); - } - - return _populateDefaultTasks(user, taskTypes); -} - -function _setProfileName (user) { - let fb = user.auth.facebook; - - let localUsername = user.auth.local && user.auth.local.username; - let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`); - let anonymous = 'Anonymous'; - - return localUsername || facebookUsername || anonymous; -} - -schema.pre('save', true, function preSaveUser (next, done) { - next(); - - if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { - this.preferences.dayStart = 0; - } - - if (!this.profile.name) { - this.profile.name = _setProfileName(this); - } - - // Determines if Beast Master should be awarded - let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); - - if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { - this.achievements.beastMaster = true; - } - - // Determines if Mount Master should be awarded - let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); - - if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { - this.achievements.mountMaster = true; - } - - // Determines if Triad Bingo should be awarded - - let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); - let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; - - if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { - this.achievements.triadBingo = true; - } - - // Enable weekly recap emails for old users who sign in - if (this.flags.lastWeeklyRecapDiscriminator) { - // Enable weekly recap emails in 24 hours - this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); - // Unset the field so this is run only once - this.flags.lastWeeklyRecapDiscriminator = undefined; - } - - // EXAMPLE CODE for allowing all existing and new players to be - // automatically granted an item during a certain time period: - // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) - // this.items.pets['JackOLantern-Base'] = 5; - - // our own version incrementer - if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; - this._v++; - - // Populate new users with default content - if (this.isNew) { - _populateDefaultsForNewUser(this) - .then(() => done()) - .catch(done); - } else { - done(); - } -}); - -schema.pre('update', function preUpdateUser () { - this.update({}, {$inc: {_v: 1}}); -}); - -schema.methods.isSubscribed = function isSubscribed () { - return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion -}; - -// Get an array of groups ids the user is member of -schema.methods.getGroups = function getUserGroups () { - let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original - if (this.party._id) userGroups.push(this.party._id); - userGroups.push(TAVERN_ID); - return userGroups; -}; - -schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) { - let sender = this; - - shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender)); - userToReceiveMessage.inbox.newMessages++; - userToReceiveMessage._v++; - userToReceiveMessage.markModified('inbox.messages'); - - shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage))); - sender.markModified('inbox.messages'); - - let promises = [userToReceiveMessage.save(), sender.save()]; - await Bluebird.all(promises); -}; - -// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) -// These will be removed once API v2 is discontinued - -// Get all the tasks belonging to a user, -schema.methods.getTasks = function getUserTasks () { - let args = Array.from(arguments); - let cb; - let type; - - if (args.length === 1) { - cb = args[0]; - } else { - type = args[0]; - cb = args[1]; - } - - let query = { - userId: this._id, - }; - - if (type) query.type = type; - - Tasks.Task.find(query, cb); -}; - -// Given user and an array of tasks, return an API compatible user + tasks obj -schema.methods.addTasksToUser = function addTasksToUser (tasks) { - let obj = this.toJSON(); - - obj.id = obj._id; - obj.filters = {}; - - obj.tags = obj.tags.map(tag => { - return { - id: tag.id, - name: tag.name, - challenge: tag.challenge, - }; - }); - - let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it - - obj.habits = []; - obj.dailys = []; - obj.todos = []; - obj.rewards = []; - - obj.tasksOrder = undefined; - let unordered = []; - - tasks.forEach((task) => { - // We want to push the task at the same position where it's stored in tasksOrder - let pos = tasksOrder[`${task.type}s`].indexOf(task._id); - if (pos === -1) { // Should never happen, it means the lists got out of sync - unordered.push(task.toJSONV2()); - } else { - obj[`${task.type}s`][pos] = task.toJSONV2(); - } - }); - - // Reconcile unordered items - unordered.forEach((task) => { - obj[`${task.type}s`].push(task); - }); - - // Remove null values that can be created when inserting tasks at an index > length - ['habits', 'dailys', 'rewards', 'todos'].forEach((type) => { - obj[type] = _.compact(obj[type]); - }); - - return obj; -}; - -// Return the data maintaining backward compatibility -schema.methods.getTransformedData = function getTransformedData (cb) { - let self = this; - this.getTasks((err, tasks) => { - if (err) return cb(err); - cb(null, self.addTasksToUser(tasks)); - }); -}; - -// END of API v2 methods -export let model = mongoose.model('User', schema); - -// Initially export an empty object so external requires will get -// the right object by reference when it's defined later -// Otherwise it would remain undefined if requested before the query executes -export let mods = []; - -mongoose.model('User') - .find({'contributor.admin': true}) - .sort('-contributor.level -backer.npc profile.name') - .select('profile contributor backer') - .exec() - .then((foundMods) => { - // Using push to maintain the reference to mods - mods.push(...foundMods); - }); // In case of failure we don't want this to crash the whole server +module.exports = schema; \ No newline at end of file diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js new file mode 100644 index 0000000000..f0b0c58211 --- /dev/null +++ b/website/server/models/userNotification.js @@ -0,0 +1,42 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/api-v3/baseModel'; +import { v4 as uuid } from 'uuid'; +import validator from 'validator'; + +const NOTIFICATION_TYPES = [ + 'DROPS_ENABLED', + 'REBIRTH_ENABLED', + 'WON_CHALLENGE', + 'STREAK_ACHIEVEMENT', + 'ULTIMATE_GEAR_ACHIEVEMENT', + 'REBIRTH_ACHIEVEMENT', + 'NEW_CONTRIBUTOR_LEVEL', + 'CRON', +]; + +const Schema = mongoose.Schema; + +export let schema = new Schema({ + id: { + type: String, + default: uuid, + validate: [validator.isUUID, 'Invalid uuid.'], + }, + type: {type: String, required: true, enum: NOTIFICATION_TYPES}, + data: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, +}, { + strict: true, + minimize: false, // So empty objects are returned + _id: false, // use id instead of _id +}); + +schema.plugin(baseModel, { + noSet: ['_id', 'id'], + timestamps: true, + private: ['updatedAt'], + _id: false, // use id instead of _id +}); + +export let model = mongoose.model('UserNotification', schema); diff --git a/website/views/index.jade b/website/views/index.jade index f3d993d1e7..4966ebf5ce 100644 --- a/website/views/index.jade +++ b/website/views/index.jade @@ -1,6 +1,6 @@ doctype html //html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":applyingAction}', ui-keypress="{27:'castCancel()'}") -html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":applyingAction}', ui-keyup="{27:'castCancel()'}") +html(ng-app='habitrpg', ng-controller='RootCtrl', ng-class='{"applying-action":applyingAction}', ui-keyup="{27:'castCancel()'}") head title(ng-bind="env.t('habitica') + ' | ' + $root.pageTitle") // ?v=1 needed to force refresh @@ -13,34 +13,50 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a meta(name='apple-mobile-web-app-capable', content='yes') meta(name='mobile-web-app-capable', content='yes') - include ./shared/new-relic - //FIXME for some reason this won't load when in footerCtrl.js#deferredScripts() - script(type="text/javascript", src="//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4", async="async") + != env.getManifestFiles("app", "css") - script(type='text/javascript'). - window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; - - != env.getManifestFiles("app") + // Does not work correctly inside .css file + style(type='text/css'). + [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none !important; + } //webfonts link(href='//fonts.googleapis.com/css?family=Lato:300,400,700,400italic,700italic', rel='stylesheet', type='text/css') + body + #loadingScreen(ng-if='!appLoaded') + // Loading screen images are inlined to avoid lag while loading (~5-6KB) + img.loading-logo-icon(src='data:image/png;base64,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') + img.loading-logo-text(src='data:image/png;base64,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') + .loading-spinner + .spinner__item1 + .spinner__item2 + .spinner__item3 + .spinner__item4 + .ng-cloak(ng-if='appLoaded', ng-controller='GroupsCtrl') + include ./shared/mixins + include ./shared/avatar/index + include ./shared/header/menu + include ./shared/modals/index + include ./shared/header/header + include ./shared/tasks/index + include ./main/index + include ./options/index + #notification-area(ng-controller='NotificationCtrl') + #wrap.container-fluid + .row + .col-md-12.exp-chart(ng-show='charts.exp') + #main(ui-view) - body(ng-cloak, ng-controller='GroupsCtrl') + include ./shared/footer include ./shared/noscript - include ./shared/mixins - include ./shared/avatar/index - include ./shared/header/menu - include ./shared/modals/index - include ./shared/header/header - include ./shared/tasks/index - include ./main/index - include ./options/index - #notification-area(ng-controller='NotificationCtrl') - #wrap.container-fluid - .row - .col-md-12.exp-chart(ng-show='charts.exp') - #main(ui-view) + // Load javascript at the end + script(type='text/javascript'). + window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; - include ./shared/footer + != env.getManifestFiles('app', 'js') + + // TODO for some reason this won't load when in footerCtrl.js#deferredScripts() + script(type='text/javascript', src='//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4', async='async')