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('`; // 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,iVBORw0KGgoAAAANSUhEUgAAAjUAAABzCAMAAABXVQPyAAAAXVBMVEVHcExDJHZDJHb/YGRDJHZDJHZHJnZJKnhHKHdDJHZDJHZDJHZDJHZDJHZDJHZDJHZDJHZDJHb/YGT/YGROtNdOtNdOtNdOtNdOtNdOtNdOtNdOtNdOtNdDJHb/YGSPhZMUAAAAHHRSTlMAsW9g8oEvHw7hw1HSP5Ggd2Cby0jkgZxmwa+2s1pZRAAACWZJREFUeF7s1m2KgzAUheHTKt5osa3ftpO4/2XOIDgx/yIYmDs9zwoO5CUJ/j4iIqLmUlWPxkAJ0zyq6pJkb5H9wCGmvj4bwSHFPM49VCtKtyprqFBve284WdYtq9Yg2jNfx1wE0frBrkYDtercbVRkc3O/Ts6mXTadII6pti15jUiT3QwCpcRH43KjbK+cG43XGUS5HB8zWW8w0Onudu7K9j5wnmzZaxGjPj6mt3sTdCrdTqlsb47zdEtAYq8azyDCywYEGhkXkI/dK0soQ4TceXHfLLGhERqJCxQfuzdbQu3xhK/RD5T3/k/VsJouTTWzDb2gAqvhXcNqziInVMN/zcft7ZZAkaYaDDbQQwFWE/lEdUhUzaztW8Nq4i8bSVUNvqw3CBRgNZHZFEhWjXn5aHoowGriHqlWkK4aYBzs6i1QitV4BiZr2zYTIGk1MP04TbNAAVYTLUE1urEaVsNqWA2rYTWshtVox2pYDathNTwFVsNqWA2r4V5Ww2pYDathNayG1ejw3d4ZLbmtwmAYGBkLAcY8AO//mmd22z2zibOWFLJ2POW/a+sigT5kIAZ+jEJ2hQK01gACLe414cE4lfSn1JDsFOcbV6YEDaie6O/sLNkld1CjMqMX5jpZS4nILi6j6VdeCwX4bLi0abdqoUGaUBQF/Hj6TtB99IefUrtXWv53M36ZpBuWjvR3/VuK3fuftVD6UrtRSH9F1s0SM7OS6ZXuqklTV2/GSg/aLeL90QcQ+Sj4Au2hoHT4WFN7rOTwk6lvf7MJ2jH+lvalMDPHoLCCVWeGV6T2SFTNk8r2J9+XP359MxiZKHjbdmSf5MbtNTY4vHGxWbPRAf7W77HgjgPgVRRmeMXUflJ4iptMu87Pt44C7kUhL43RgkatnJhCQ/SiQe7W3/I6fwO/ZxuhyVWlZnjN1PaU1H0Z2XabMN38cS8K0FiFrPVwabxuc9EmvR/gbxZkiqUpFIRmeFVgY2xUiqCNSNpEgVGnizOTaNjsfpC/rn0XsXmCV9ab0ecFZgrBwC9XdxSaRdUBbnpZNTX9/k58osCmkpOYYYXUJAreCIXUTqGmESpO0Tufmkaop6bf+PoKalCaqiGLc/9J1LSEinnJ+dQ0whNyTZWb4aHhBV4ETWjHUqPPNrmdRw3vrz6cum7qVWb63yZhNpLUdSI1zRpeHt6GmmZfQY1Tgao00z9uJcOK2rHU6GcmGNr7UNNWJTXdySb3UxObTgu//H0yNfwKlW3vRE3LKmq6RwXVdFMzw4vrWNvvU0OLqzFWVx73sIDPuAh2qjHGOtkgpEbr78r52xNOFPZWykIz+m6XyjIthR7/2zMUpp04K6lJFb895QKTD4Wr7/Z7gvILvI4auvF3DcJ3qj6cc12nL915PX3JeakZ7Vzi2y/S0fIZjn8/JcfEWUFNipvM8aA8r3TRzrIfG/TUpE3XdsD4y4Sz+/uafjPE/eA0b7lJOgoDE2eGGv4nP7RccJmCIQq/PNBTM8l+8SuXoiYLqllBMdokRZz11EA1D7VqOm8RriYgdVMDUTptna9ETZG8fTKImy6LB+w8NZqBuNP/ysgvQSF1UgPZGGH1lwtRgyCKcQZpz7DitOTk1GwL47MNIGuYX7fE1EdNlC+SAV6HmigkvgrHw7Ni4Lxy1OhmGlZqOSkWEmbooWbSDCjjdagp0nUDktHlNDNgq6QmqabTlg8uH1oTFdT0rVCU61ATpLx7GQ2kWb3CwFDTtboIzGPCBUFiqOH8FefacBlqUN45SOIF6lb2q4oaMoyCaBZVdO2Z5dQwT3LJ0b8lNXyTrOIIz6LyEhtnDTXRGFXndRKbgIZRepaaqItSq1ehpm5ol0W4SNuBkWOo0cV3Fr1FGQgYH+XUgOHk+QHW+dTwvVM6LgwoG1zzcVZQYw2rIPgPs7Y5vdCNfn/fkRr+uSQchaRZNvghwyrJqZkMqyKoTlbv4YfnqFm1N5rSVahZVFH2JXw85aQNvCjizFNT1dWWTKVRSLaemqjdxpGuSg0rRKZbKl11cmqytjQQDOXA8LIiavr9DVelplfq3lY7qdET4Y6ixqv9/UepQTU1UU6NVzN4RWoGNXlQM6gZ1AxqBjWDmqfMDGoGNYOaQc2gZlAzqBnUDGoGNYOaQc2gZlAzqBnUDGoGNYOaQc2gZlAzqBnUjC8lxldZ46ssPTXjC9BrUKP/AvQAasbX5qdSgysFSCWf87W5nprjdraMnS38tQMFz9jZoqfmiF10YxcdSM91JnPCLjo9NYfs2B07dmdp600n7NjVU3PI6QDjdAAnbT3A408H0FPz5ieRuEucRMJXlMTj1vqik0iOp2acetRvJkgjM59+6pGemmNOWBsnrBHz4JknrOmpGac59lIjbMEqHBO6l5zmeAY14+TYfjO4qaYsUfvjT47VUzNOqe6lRhpl8JJLZOgVp1SfRM04Eb/fTBQ0YA48CfoT8U+jZty+0W8mPYgy13wB+2/fOJMahLZVOfemn2tRU9tGMM37lVzPuOlHT82VbhW7GDWGfrgDLMbqFmLqeOCtYnpqrnSD4dWo8U2reMoNhnpqLn1b6vnUcA/rVAyj8u7UXP9m5vOpMdQ0Cmg40ftTY+Jlb4E/nxr9Tf8giB6m96fGxDehhtBcjBr95cwQjUBzeH9qTHwLagjN1ajRv+WjEWlO70+NyXA+NRbNEdRU8xtmvCw5QDZCIXVTgww1XPaAX2G7vJSayUjknthKchvQrDeTXpUcgjdyLb3UmKTdueS1rYsSJ4N0lgw3zIKgZGEnjAy37EQW8JfMYNFlU14RtBFJOxnTqjvYagTKifMw+qdufLZ8gy7S9kTQDxJmJqWxZqowyqHtCapRStJuaadyCNocWzcrBLzcXrVhRWNI2AP93TvB27Yjq0jcEzPeYD5fIuww05WvFzR6ZWo7KrMxdS+PRm1vMXaLGa+a2mMlh3c0JGQCdUuXLz/RWLzRiDYrHwpsCHvM8JoXeFzJZTbPKdvWdoukvdQbYTNzFL7OIRuF/LQFJyx5M9kiphnc5n2K1cKm6rai0QntZijEy3+aTvX3zGxqyVRSUyI9aLeIX6DS3krQ/BHOUBTM5hIapEntMcappPDhKUCyU5zv3IAGtgq6XWgt2Hjrkiv0t+RAi/PmGUUbGtCqqxd6jx1mVMrrVy0DlTV3IHPbbp9FJip37VYtMHG+poaGhoaGhoaG/gMEQjcrG+cXsQAAAABJRU5ErkJggg==')
+ .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')