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
This commit is contained in:
Matteo Pagliazzi
2016-06-07 16:14:19 +02:00
parent e0aff79ee4
commit f7be7205e7
49 changed files with 915 additions and 436 deletions

View File

@@ -58,5 +58,6 @@
"messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!", "messageGroupChatAdminClearFlagCount": "Only an admin can clear the flag count!",
"messageUserOperationProtected": "path `<%= operation %>` was not saved, as it's a protected path.", "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."
} }

View File

@@ -2,4 +2,5 @@ export const MAX_HEALTH = 50;
export const MAX_LEVEL = 100; export const MAX_LEVEL = 100;
export const MAX_STAT_POINTS = MAX_LEVEL; export const MAX_STAT_POINTS = MAX_LEVEL;
export const ATTRIBUTES = ['str', 'int', 'per', 'con']; 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';

View File

@@ -12,6 +12,10 @@ module.exports = function ultimateGear (user) {
}); });
return soFarGood && (!found || owned[found.key] === true); return soFarGood && (!found || owned[found.key] === true);
}, true); }, true);
if (user.achievements.ultimateGearSets[klass] === true) {
user.addNotification('ULTIMATE_GEAR_ACHIEVEMENT');
}
} }
}); });

View File

@@ -59,6 +59,8 @@ module.exports = function updateStats (user, stats, req = {}, analytics) {
} }
if (!user.flags.dropsEnabled && user.stats.lvl >= 3) { if (!user.flags.dropsEnabled && user.stats.lvl >= 3) {
user.flags.dropsEnabled = true; user.flags.dropsEnabled = true;
user.addNotification('DROPS_ENABLED');
if (user.items.eggs.Wolf > 0) { if (user.items.eggs.Wolf > 0) {
user.items.eggs.Wolf++; user.items.eggs.Wolf++;
} else { } else {
@@ -92,6 +94,7 @@ module.exports = function updateStats (user, stats, req = {}, analytics) {
} }
}); });
if (!user.flags.rebirthEnabled && (user.stats.lvl >= 50 || user.achievements.beastMaster)) { if (!user.flags.rebirthEnabled && (user.stats.lvl >= 50 || user.achievements.beastMaster)) {
user.addNotification('REBIRTH_ENABLED');
user.flags.rebirthEnabled = true; user.flags.rebirthEnabled = true;
} }
}; };

View File

@@ -242,6 +242,11 @@ api.wrap = function wrapUser (user, main = true) {
user.markModified = function noopMarkModified () {}; user.markModified = function noopMarkModified () {};
} }
// same for addNotification
if (!user.addNotification) {
user.addNotification = function noopAddNotification () {};
}
if (main) { if (main) {
user.ops = { user.ops = {
update: _.partial(importedOps.update, user), update: _.partial(importedOps.update, user),

View File

@@ -48,7 +48,6 @@ import openMysteryItem from './openMysteryItem';
import scoreTask from './scoreTask'; import scoreTask from './scoreTask';
import markPmsRead from './markPMSRead'; import markPmsRead from './markPMSRead';
module.exports = { module.exports = {
update, update,
sleep, sleep,

View File

@@ -93,6 +93,8 @@ module.exports = function rebirth (user, tasks = [], req = {}, analytics) {
user.achievements.rebirthLevel = lvl; user.achievements.rebirthLevel = lvl;
} }
user.addNotification('REBIRTH_ACHIEVEMENT');
user.stats.buffs = {}; user.stats.buffs = {};
if (req.v2 === true) { if (req.v2 === true) {

View File

@@ -214,7 +214,10 @@ module.exports = function scoreTask (options = {}, req = {}) {
if (direction === 'up') { if (direction === 'up') {
task.streak += 1; task.streak += 1;
// Give a streak achievement when the streak is a multiple of 21 // 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; task.completed = true;
} else if (direction === 'down') { } else if (direction === 'down') {
// Remove a streak achievement if streak was a multiple of 21 and the daily was undone // Remove a streak achievement if streak was a multiple of 21 and the daily was undone

View File

@@ -100,6 +100,8 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => {
await sleep(0.5); await sleep(0.5);
await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); 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 () => { it('gives winner gems as reward', async () => {

View File

@@ -17,16 +17,19 @@ describe('GET /export/history.csv', () => {
]); ]);
// score all the tasks twice // score all the tasks twice
await Promise.all(tasks.map(task => { await user.post(`/tasks/${tasks[0]._id}/score/up`);
return user.post(`/tasks/${task._id}/score/up`); await user.post(`/tasks/${tasks[1]._id}/score/up`);
})); await user.post(`/tasks/${tasks[2]._id}/score/up`);
await Promise.all(tasks.map(task => { await user.post(`/tasks/${tasks[3]._id}/score/up`);
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`);
// adding an history entry to daily 1 manually because cron didn't run yet // adding an history entry to daily 1 manually because cron didn't run yet
await updateDocument('tasks', tasks[1], { await updateDocument('tasks', tasks[1], {
history: {value: 3.2, date: Number(new Date())}, history: [{value: 3.2, date: Number(new Date())}],
}); });
// get updated tasks // get updated tasks

View File

@@ -68,6 +68,8 @@ describe('PUT /heroes/:heroId', () => {
expect(hero.contributor.level).to.equal(1); expect(hero.contributor.level).to.equal(1);
expect(hero.purchased.ads).to.equal(true); expect(hero.purchased.ads).to.equal(true);
expect(hero.auth.blocked).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 () => { it('updates contributor level', async () => {

View File

@@ -46,6 +46,9 @@ describe('POST /user/rebirth', () => {
let response = await user.post('/user/rebirth'); let response = await user.post('/user/rebirth');
await user.sync(); 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 updatedDaily = await user.get(`/tasks/${daily._id}`);
let updatedReward = await user.get(`/tasks/${reward._id}`); let updatedReward = await user.get(`/tasks/${reward._id}`);

View File

@@ -36,6 +36,7 @@ describe('PUT /user', () => {
backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'}, backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'},
subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000},
'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true},
notifications: [{type: 123}],
}; };
each(protectedOperations, (data, testName) => { each(protectedOperations, (data, testName) => {

View File

@@ -10,6 +10,18 @@ describe('Build Manifest', () => {
expect(htmlCode.startsWith('<script') || htmlCode.startsWith('<link')).to.be.true; expect(htmlCode.startsWith('<script') || htmlCode.startsWith('<link')).to.be.true;
}); });
it('can return only js files', () => {
let htmlCode = getManifestFiles('app', 'js');
expect(htmlCode.indexOf('<link') === -1).to.be.true;
});
it('can return only css files', () => {
let htmlCode = getManifestFiles('app', 'css');
expect(htmlCode.indexOf('<script') === -1).to.be.true;
});
it('throws an error in case the page does not exist', () => { it('throws an error in case the page does not exist', () => {
expect(() => { expect(() => {
getManifestFiles('strange name here'); getManifestFiles('strange name here');

View File

@@ -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', () => { describe('private messages', () => {
let lastMessageId; let lastMessageId;

View File

@@ -32,6 +32,7 @@ describe('response middleware', () => {
expect(res.json).to.be.calledWith({ expect(res.json).to.be.calledWith({
success: true, success: true,
data: {field: 1}, data: {field: 1},
notifications: [],
}); });
}); });
@@ -47,6 +48,7 @@ describe('response middleware', () => {
success: true, success: true,
data: {field: 1}, data: {field: 1},
message: 'hello', message: 'hello',
notifications: [],
}); });
}); });
@@ -61,6 +63,45 @@ describe('response middleware', () => {
expect(res.json).to.be.calledWith({ expect(res.json).to.be.calledWith({
success: false, success: false,
data: {field: 1}, 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: {},
},
],
}); });
}); });
}); });

View File

@@ -30,4 +30,30 @@ describe('User Model', () => {
expect(toJSON._tmp).to.eql({ok: true}); expect(toJSON._tmp).to.eql({ok: true});
expect(toJSON).to.not.have.keys('_nonTmp'); 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});
});
});
}); });

View File

@@ -11,6 +11,7 @@ describe('shared.fns.ultimateGear', () => {
user.achievements.ultimateGearSets.toObject = function () { user.achievements.ultimateGearSets.toObject = function () {
return this; return this;
}; };
user.addNotification = sinon.spy();
}); });
it('sets armoirEnabled when partial achievement already achieved', () => { it('sets armoirEnabled when partial achievement already achieved', () => {
@@ -31,7 +32,10 @@ describe('shared.fns.ultimateGear', () => {
user.items = items; user.items = items;
ultimateGear(user); ultimateGear(user);
expect(user.flags.armoireEnabled).to.equal(true); 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', () => { it('does not set armoirEnabled when gear is not owned', () => {

View File

@@ -8,6 +8,7 @@ describe('common.fns.updateStats', () => {
beforeEach(() => { beforeEach(() => {
user = generateUser(); user = generateUser();
user.addNotification = sinon.spy();
}); });
context('No Hp', () => { context('No Hp', () => {
@@ -109,6 +110,20 @@ describe('common.fns.updateStats', () => {
expect(user.stats.points).to.eql(10); 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', () => { context('assigns flags.levelDrops', () => {
it('for atom1', () => { it('for atom1', () => {
user.stats.lvl = 16; user.stats.lvl = 16;

View File

@@ -16,7 +16,7 @@ afterEach((done) => {
export { sleep } from './sleep'; export { sleep } from './sleep';
export function generateUser (options = {}) { export function generateUser (options = {}) {
return new User(options).toObject(); return new User(options);
} }
export function generateGroup (options = {}) { export function generateGroup (options = {}) {

View File

@@ -9,6 +9,7 @@ describe('Filters Controller', function() {
scope = $rootScope.$new(); scope = $rootScope.$new();
// user.filters = {}; // user.filters = {};
User.setUser(user); User.setUser(user);
User.user.filters = {};
userService = User; userService = User;
$controller('FiltersCtrl', {$scope: scope, User: User}); $controller('FiltersCtrl', {$scope: scope, User: User});
})); }));

View File

@@ -31,9 +31,7 @@ describe('userServices', function() {
it('saves user data to local storage', function(){ it('saves user data to local storage', function(){
user.save(); user.save();
var settings = JSON.parse(localStorage[STORAGE_SETTINGS_ID]); var settings = JSON.parse(localStorage[STORAGE_SETTINGS_ID]);
var user_id = JSON.parse(localStorage[STORAGE_USER_ID]);
expect(settings).to.eql(user.settings); expect(settings).to.eql(user.settings);
expect(user_id).to.eql(user.user);
}); });
xit('alerts when not authenticated', function(){ xit('alerts when not authenticated', function(){

View File

@@ -30,6 +30,7 @@
@import "./menu.styl" @import "./menu.styl"
@import "./options.styl" @import "./options.styl"
@import "./no-script.styl" @import "./no-script.styl"
@import "./loading-screen.styl"
html,body,p,h1,ul,li,table,tr,th,td html,body,p,h1,ul,li,table,tr,th,td
margin: 0 margin: 0

View File

@@ -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
}

View File

@@ -10,9 +10,9 @@ angular.module('habitrpg')
// If it was, sync // If it was, sync
function verifyUserUpdated (response) { function verifyUserUpdated (response) {
var isApiCall = response.config.url.indexOf('api/v3') !== -1; 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 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) { if (isApiCall && isUserAvailable && hasUserV) {
var oldUserV = $rootScope.User.user._v; 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 { return {
request: function (config) { request: function (config) {
var url = config.url; var url = config.url;
@@ -43,6 +61,7 @@ angular.module('habitrpg')
}, },
response: function(response) { response: function(response) {
verifyUserUpdated(response); verifyUserUpdated(response);
verifyNewNotifications(response);
return response; return response;
}, },
responseError: function(response) { responseError: function(response) {

View File

@@ -5,6 +5,7 @@ habitrpg
function($scope, $rootScope, Members, Shared, $http, Notification, Groups, Chat, $controller, Stats) { function($scope, $rootScope, Members, Shared, $http, Notification, Groups, Chat, $controller, Stats) {
$controller('RootCtrl', {$scope: $scope}); $controller('RootCtrl', {$scope: $scope});
$rootScope.appLoaded = true;
$scope.timestamp = function(timestamp){ $scope.timestamp = function(timestamp){
return moment(timestamp).format($rootScope.User.user.preferences.dateFormat.toUpperCase()); return moment(timestamp).format($rootScope.User.user.preferences.dateFormat.toUpperCase());

View File

@@ -23,17 +23,6 @@ habitrpg.controller('NotificationCtrl',
Notification.exp(after - before); 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) { $rootScope.$watch('user.stats.gp', function(after, before) {
if (after == before) return; if (after == before) return;
if (User.user.stats.lvl == 0) return; if (User.user.stats.lvl == 0) return;
@@ -82,35 +71,89 @@ habitrpg.controller('NotificationCtrl',
} }
}); });
$rootScope.$watch('user.achievements.streak', function(after, before){ // Avoid showing the same notiication more than once
if(before == undefined || after <= before) return; var lastShownNotifications = [];
Notification.streak(User.user.achievements.streak);
$rootScope.playSound('Achievement_Unlocked'); function handleUserNotifications (after) {
if (!User.user.preferences.suppressModals.streak) { if (!after || after.length === 0) return;
$rootScope.openModal('achievements/streak', {controller:'UserCtrl'});
} 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){ var handleUserNotificationsOnFirstSync = _.once(function () {
if (_.isEqual(after,before) || !_.contains(User.user.achievements.ultimateGearSets, true)) return; handleUserNotifications($rootScope.userNotifications);
$rootScope.openModal('achievements/ultimateGear', {controller:'UserCtrl'}); });
$rootScope.$on('userUpdated', handleUserNotificationsOnFirstSync);
// TODO what about this?
$rootScope.$watch('user.achievements', function(){
$rootScope.playSound('Achievement_Unlocked');
}, true); }, true);
$rootScope.$watch('user.flags.armoireEmpty', function(after,before){ $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.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 // Completed quest modal
$scope.$watch('user.party.quest.completed', function(after, before){ $scope.$watch('user.party.quest.completed', function(after, before){
if (!after) return; if (!after) return;

View File

@@ -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.TAVERN_ID = TAVERN_ID;
$rootScope.User = User; $rootScope.User = User;
$rootScope.user = user; $rootScope.user = user;
@@ -39,7 +40,8 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
$rootScope.Groups = Groups; $rootScope.Groups = Groups;
$rootScope.toJson = angular.toJson; $rootScope.toJson = angular.toJson;
$rootScope.Payments = Payments; $rootScope.Payments = Payments;
$rootScope.userNotifications = [];
// Angular UI Router // Angular UI Router
$rootScope.$state = $state; $rootScope.$state = $state;
$rootScope.$stateParams = $stateParams; $rootScope.$stateParams = $stateParams;

View File

@@ -289,16 +289,6 @@ function($rootScope, User, $timeout, $state, Analytics) {
case 'options.inventory.equipment': return goto('equipment', 0); 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 = { var Guide = {

View File

@@ -14,6 +14,7 @@ angular.module('habitrpg')
return $http({ return $http({
method: 'GET', method: 'GET',
url: url, url: url,
ignoreLoadingBar: $rootScope.appLoaded !== true,
}); });
}; };

View File

@@ -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,
};
}]);

View File

@@ -14,8 +14,8 @@ angular.module('habitrpg')
/** /**
* Services that persists and retrieves user from localStorage. * 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', .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) { function($rootScope, $http, $location, $window, STORAGE_USER_ID, STORAGE_SETTINGS_ID, Notification, ApiUrl, Tasks, Tags, Content, UserNotifications) {
var authenticated = false; var authenticated = false;
var defaultSettings = { var defaultSettings = {
auth: { apiId: '', apiToken: ''}, auth: { apiId: '', apiToken: ''},
@@ -38,11 +38,6 @@ angular.module('habitrpg')
//first we populate user with schema //first we populate user with schema
user.apiToken = user._id = ''; // we use id / apitoken to determine if registered 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; user._wrapped = false;
function syncUserTasks (tasks) { function syncUserTasks (tasks) {
@@ -78,6 +73,7 @@ angular.module('habitrpg')
return $http({ return $http({
method: "GET", method: "GET",
url: '/api/v3/user/', url: '/api/v3/user/',
ignoreLoadingBar: $rootScope.appLoaded !== true,
}) })
.then(function (response) { .then(function (response) {
if (response.data.message) Notification.text(response.data.message); if (response.data.message) Notification.text(response.data.message);
@@ -108,14 +104,14 @@ angular.module('habitrpg')
.then(function (response) { .then(function (response) {
var tasks = response.data.data; var tasks = response.data.data;
syncUserTasks(tasks); syncUserTasks(tasks);
save();
$rootScope.$emit('userSynced'); $rootScope.$emit('userSynced');
$rootScope.appLoaded = true;
}); });
} }
var save = function () { var save = function () {
localStorage.setItem(STORAGE_USER_ID, JSON.stringify(user));
localStorage.setItem(STORAGE_SETTINGS_ID, JSON.stringify(settings)); 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) { function callOpsFunctionAndRequest (opName, endPoint, method, paramString, opData) {
@@ -167,8 +163,6 @@ angular.module('habitrpg')
var text = Content.gear.flat[openedItem.key].text(); var text = Content.gear.flat[openedItem.key].text();
Notification.drop(env.t('messageDropMysteryItem', {dropText: text}), openedItem); Notification.drop(env.t('messageDropMysteryItem', {dropText: text}), openedItem);
} }
save();
}) })
} }
@@ -214,7 +208,6 @@ angular.module('habitrpg')
} else { } else {
user.ops.addTask(data); user.ops.addTask(data);
} }
save();
Tasks.createUserTasks(data.body); Tasks.createUserTasks(data.body);
}, },
@@ -225,7 +218,6 @@ angular.module('habitrpg')
Notification.text(err.message); Notification.text(err.message);
return; return;
} }
save();
Tasks.scoreTask(data.params.task._id, data.params.direction).then(function (res) { 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 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) { sortTask: function (data) {
user.ops.sortTask(data); user.ops.sortTask(data);
save();
Tasks.moveTask(data.params.id, data.query.to); Tasks.moveTask(data.params.id, data.query.to);
}, },
updateTask: function (task, data) { updateTask: function (task, data) {
$window.habitrpgShared.ops.updateTask(task, data); $window.habitrpgShared.ops.updateTask(task, data);
save();
Tasks.updateTask(task._id, data.body); Tasks.updateTask(task._id, data.body);
}, },
deleteTask: function (data) { deleteTask: function (data) {
user.ops.deleteTask(data); user.ops.deleteTask(data);
save();
Tasks.deleteTask(data.params.id); Tasks.deleteTask(data.params.id);
}, },
clearCompleted: function () { clearCompleted: function () {
user.ops.clearCompleted(user.todos); user.ops.clearCompleted(user.todos);
save();
Tasks.clearCompletedTodos(); Tasks.clearCompletedTodos();
}, },
readNotification: function (notificationId) {
UserNotifications.readNotification(notificationId);
},
addTag: function(data) { addTag: function(data) {
user.ops.addTag(data); user.ops.addTag(data);
save();
Tags.createTag(data.body); Tags.createTag(data.body);
}, },
updateTag: function(data) { updateTag: function(data) {
user.ops.updateTag(data); user.ops.updateTag(data);
save();
Tags.updateTag(data.params.id, data.body); Tags.updateTag(data.params.id, data.body);
}, },
@@ -326,7 +316,6 @@ angular.module('habitrpg')
deleteTag: function(data) { deleteTag: function(data) {
user.ops.deleteTag(data); user.ops.deleteTag(data);
save();
Tags.deleteTag(data.params.id); Tags.deleteTag(data.params.id);
}, },
@@ -495,7 +484,6 @@ angular.module('habitrpg')
data: updates, data: updates,
}) })
.then(function () { .then(function () {
save();
$rootScope.$emit('userSynced'); $rootScope.$emit('userSynced');
}) })
}, },

View File

@@ -38,11 +38,11 @@
"js/env.js", "js/env.js",
"js/app.js", "js/app.js",
"common/script/public/config.js", "js/config.js",
"js/services/sharedServices.js", "js/services/sharedServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"common/script/public/directives.js", "js/directives/directives.js",
"js/services/analyticsServices.js", "js/services/analyticsServices.js",
"js/services/groupServices.js", "js/services/groupServices.js",
"js/services/chatServices.js", "js/services/chatServices.js",
@@ -55,6 +55,7 @@
"js/services/questServices.js", "js/services/questServices.js",
"js/services/socialServices.js", "js/services/socialServices.js",
"js/services/statServices.js", "js/services/statServices.js",
"js/services/userNotificationsService.js",
"js/services/userServices.js", "js/services/userServices.js",
"js/services/hallServices.js", "js/services/hallServices.js",
@@ -131,6 +132,7 @@
"js/static.js", "js/static.js",
"js/services/analyticsServices.js", "js/services/analyticsServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"js/services/userNotificationsService.js",
"js/services/userServices.js", "js/services/userServices.js",
"js/services/sharedServices.js", "js/services/sharedServices.js",
"js/services/socialServices.js", "js/services/socialServices.js",
@@ -173,6 +175,7 @@
"js/services/statServices.js", "js/services/statServices.js",
"js/services/taskServices.js", "js/services/taskServices.js",
"js/services/tagsServices.js", "js/services/tagsServices.js",
"js/services/userNotificationsService.js",
"js/services/userServices.js", "js/services/userServices.js",
"js/services/memberServices.js", "js/services/memberServices.js",
"js/controllers/authCtrl.js", "js/controllers/authCtrl.js",

View File

@@ -154,6 +154,8 @@ api.updateHero = {
tierDiff--; tierDiff--;
newTier--; // give them gems for the next tier down if they weren't aready that tier 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); if (updateData.contributor) _.assign(hero.contributor, updateData.contributor);

View File

@@ -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;

View File

@@ -39,7 +39,7 @@ export function getBuildUrl (url) {
return `/${buildFiles[url] || url}`; return `/${buildFiles[url] || url}`;
} }
export function getManifestFiles (page) { export function getManifestFiles (page, type) {
let files = manifestFiles[page]; let files = manifestFiles[page];
if (!files) throw new Error(`Page "${page}" not found!`); if (!files) throw new Error(`Page "${page}" not found!`);
@@ -47,15 +47,25 @@ export function getManifestFiles (page) {
let htmlCode = ''; let htmlCode = '';
if (IS_PROD) { if (IS_PROD) {
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template if (type !== 'js') {
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template
}
if (type !== 'css') {
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template
}
} else { } else {
files.css.forEach((file) => { if (type !== 'js') {
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`; files.css.forEach((file) => {
}); htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`;
files.js.forEach((file) => { });
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`; }
});
if (type !== 'css') {
files.js.forEach((file) => {
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`;
});
}
} }
return htmlCode; return htmlCode;

View File

@@ -111,6 +111,9 @@ function performSleepTasks (user, tasksByType, now) {
export function cron (options = {}) { export function cron (options = {}) {
let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = 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.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
// User is only allowed a certain number of drops a day. This resets the count. // 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; if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
@@ -279,6 +282,25 @@ export function cron (options = {}) {
let _progress = _.cloneDeep(progress); let _progress = _.cloneDeep(progress);
_.merge(progress, {down: 0, up: 0, collectedItems: 0}); _.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 // 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; // let numberOfPMs = Object.keys(user.inbox.messages).length;
// if (numberOfPMs > maxPMs) { // if (numberOfPMs > maxPMs) {

View File

@@ -14,8 +14,11 @@ module.exports = function responseHandler (req, res, next) {
// sends back the current user._v in the response so that the client // sends back the current user._v in the response so that the client
// can verify if it's the most up to date data. // can verify if it's the most up to date data.
// Considered part of the private API for now and not officially supported // Considered part of the private API for now and not officially supported
if (user && req.query.userV) { if (user) {
response.userV = user._v; response.notifications = user.notifications.map(notification => notification.toJSON());
if (req.query.userV) {
response.userV = user._v;
}
} }
res.status(status).json(response); res.status(status).json(response);

View File

@@ -12,7 +12,6 @@ module.exports = function staticMiddleware (expressApp) {
expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE })); 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/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/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('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE }));
expressApp.use(express.static(PUBLIC_DIR)); expressApp.use(express.static(PUBLIC_DIR));
}; };

View File

@@ -15,7 +15,7 @@ import { sendTxn as txnEmail } from '../libs/api-v3/email';
import sendPushNotification from '../libs/api-v3/pushNotifications'; import sendPushNotification from '../libs/api-v3/pushNotifications';
import cwait from 'cwait'; import cwait from 'cwait';
let Schema = mongoose.Schema; const Schema = mongoose.Schema;
let schema = new Schema({ let schema = new Schema({
name: {type: String, required: true}, name: {type: String, required: true},
@@ -286,7 +286,11 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
if (winner) { if (winner) {
winner.achievements.challenges.push(challenge.name); winner.achievements.challenges.push(challenge.name);
winner.balance += challenge.prize / 4; winner.balance += challenge.prize / 4;
winner.addNotification('WON_CHALLENGE');
let savedWinner = await winner.save(); let savedWinner = await winner.save();
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
txnEmail(savedWinner, 'won-challenge', [ txnEmail(savedWinner, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: challenge.name}, {name: 'CHALLENGE_NAME', content: challenge.name},

View File

@@ -3,7 +3,7 @@ import baseModel from '../libs/api-v3/baseModel';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import validator from 'validator'; import validator from 'validator';
let Schema = mongoose.Schema; const Schema = mongoose.Schema;
export let schema = new Schema({ export let schema = new Schema({
id: { id: {

View File

@@ -6,7 +6,8 @@ import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash'; import _ from 'lodash';
import { preenHistory } from '../libs/api-v3/preening'; import { preenHistory } from '../libs/api-v3/preening';
let Schema = mongoose.Schema; const Schema = mongoose.Schema;
let discriminatorOptions = { let discriminatorOptions = {
discriminatorKey: 'type', // the key that distinguishes task types discriminatorKey: 'type', // the key that distinguishes task types
}; };

View File

@@ -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}});
});

View File

@@ -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);
});

View File

@@ -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

View File

@@ -1,22 +1,16 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import shared from '../../../common'; import shared from '../../../../common';
import _ from 'lodash'; import _ from 'lodash';
import validator from 'validator'; import validator from 'validator';
import moment from 'moment'; import { schema as TagSchema } from '../tag';
import * as Tasks from './task';
import Bluebird from 'bluebird';
import { schema as TagSchema } from './tag';
import baseModel from '../libs/api-v3/baseModel';
import { import {
chatDefaults, schema as UserNotificationSchema,
TAVERN_ID, } from '../userNotification';
} from './group';
import { defaults } from 'lodash';
let Schema = mongoose.Schema; const Schema = mongoose.Schema;
// User schema definition // User schema definition
export let schema = new Schema({ let schema = new Schema({
apiToken: { apiToken: {
type: String, type: String,
default: shared.uuid, default: shared.uuid,
@@ -495,6 +489,7 @@ export let schema = new Schema({
}, },
}, },
notifications: [UserNotificationSchema],
tags: [TagSchema], tags: [TagSchema],
inbox: { inbox: {
@@ -514,312 +509,13 @@ export let schema = new Schema({
extra: {type: Schema.Types.Mixed, default: () => { extra: {type: Schema.Types.Mixed, default: () => {
return {}; return {};
}}, }},
pushDevices: { pushDevices: [{
type: [{ regId: {type: String},
regId: {type: String}, type: {type: String},
type: {type: String}, }],
}],
default: () => [],
},
}, { }, {
strict: true, strict: true,
minimize: false, // So empty objects are returned minimize: false, // So empty objects are returned
}); });
schema.plugin(baseModel, { module.exports = schema;
// 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

View File

@@ -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);

View File

@@ -1,6 +1,6 @@
doctype html 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-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 head
title(ng-bind="env.t('habitica') + ' | ' + $root.pageTitle") title(ng-bind="env.t('habitica') + ' | ' + $root.pageTitle")
// ?v=1 needed to force refresh // ?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='apple-mobile-web-app-capable', content='yes')
meta(name='mobile-web-app-capable', content='yes') meta(name='mobile-web-app-capable', content='yes')
include ./shared/new-relic != env.getManifestFiles("app", "css")
//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")
script(type='text/javascript'). // Does not work correctly inside .css file
window.env = !{JSON.stringify(env._.pick(env, env.clientVars))}; style(type='text/css').
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
!= env.getManifestFiles("app") display: none !important;
}
//webfonts //webfonts
link(href='//fonts.googleapis.com/css?family=Lato:300,400,700,400italic,700italic', rel='stylesheet', type='text/css') 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/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') // Load javascript at the end
#wrap.container-fluid script(type='text/javascript').
.row window.env = !{JSON.stringify(env._.pick(env, env.clientVars))};
.col-md-12.exp-chart(ng-show='charts.exp')
#main(ui-view)
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')