Notifications v2 and Bailey API (#9716)

* Added initial bailey api

* wip

* implement new panel header

* Fixed lint

* add ability to mark notification as seen

* add notification count, remove top badge from user and add ability to mark multiple notifications as seen

* add support dismissall and mark all as read

* do not dismiss actionable notif

* mark as seen when menu is opened instead of closed

* implement ordering, list of actionable notifications

* add groups messages and fix badges count

* add notifications for received cards

* send card received notification to target not sender

* rename notificaion field

* fix integration tests

* mark cards notifications as read and update tests

* add mystery items notifications

* add unallocated stats points notifications

* fix linting

* simplify code

* refactoring and fixes

* fix dropdown opening

* start splitting notifications into their own component

* add notifications for inbox messages

* fix unit tests

* fix default buttons styles

* add initial bailey support

* add title and tests to new stuff notification

* add notification if a group task needs more work

* add tests and fixes for marking a task as needing more work

* make sure user._v is updated

* remove console.log

* notification: hover status and margins

* start styling notifications, add separate files and basic functionalities

* fix tests

* start adding mystery items notification

* wip card notification

* fix cards text

* initial implementation inbox messages

* initial implementation group messages

* disable inbox notifications until mobile is ready

* wip group chat messages

* finish mystery and card notifications

* add bailey notification and fix a lot of stuff

* start adding guilds and parties invitations

* misc invitation fixes

* fix lint issues

* remove old code and add key to notifications

* fix tests

* remove unused code

* add link for public guilds invite

* starts to implement needs work notification design and feature

* fixes to needs work, add group task approved notification

* finish needs work feature

* lots of fixes

* implement quest notification

* bailey fixes and static page

* routing fixes

* fixes #      this.$store.dispatch(guilds:join, {groupId: group.id, type: party});

* read notifications on click

* chat notifications

* fix tests for chat notifications

* fix chat notification test

* fix tests

* fix tests (again)

* try awaiting

* remove only

* more sleep

* add bailey tests

* fix icons alignment

* fix issue with multiple points notifications

* remove merge code

* fix rejecting guild invitation

* make remove area bigger

* fix error with notifications and add migration

* fix migration

* fix typos

* add cleanup migration too

* notifications empty state, new counter color, fix marking messages as seen in guilds

* fixes

* add image and install correct packages

* fix mongoose version

* update bailey

* typo

* make sure chat is marked as read after other requests
This commit is contained in:
Matteo Pagliazzi
2018-01-31 11:55:39 +01:00
committed by GitHub
parent a85282763f
commit 33b249d078
98 changed files with 3003 additions and 1026 deletions

View File

@@ -0,0 +1,93 @@
const UserNotification = require('../website/server/models/userNotification').model;
const content = require('../website/common/script/content/index');
const migrationName = '20180125_clean_new_migrations';
const authorName = 'paglias'; // in case script author needs to know when their ...
const authorUuid = 'ed4c688c-6652-4a92-9d03-a5a79844174a'; // ... own data is done
/*
* Clean new migration types for processed users
*/
const monk = require('monk');
const connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
const dbUsers = monk(connectionString).get('users', { castIds: false });
const progressCount = 1000;
let count = 0;
function updateUser (user) {
count++;
const types = ['NEW_MYSTERY_ITEMS', 'CARD_RECEIVED', 'NEW_CHAT_MESSAGE'];
dbUsers.update({_id: user._id}, {
$pull: {notifications: { type: {$in: types } } },
$set: {migration: migrationName},
});
if (count % progressCount === 0) console.warn(`${count } ${ user._id}`);
if (user._id === authorUuid) console.warn(`${authorName } processed`);
}
function exiting (code, msg) {
code = code || 0; // 0 = success
if (code && !msg) {
msg = 'ERROR!';
}
if (msg) {
if (code) {
console.error(msg);
} else {
console.log(msg);
}
}
process.exit(code);
}
function displayData () {
console.warn(`\n${ count } users processed\n`);
return exiting(0);
}
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users found and modified.');
displayData();
return;
}
const userPromises = users.map(updateUser);
const lastUser = users[users.length - 1];
return Promise.all(userPromises)
.then(() => {
processUsers(lastUser._id);
});
}
function processUsers (lastId) {
// specify a query to limit the affected users (empty for all users):
const query = {
migration: {$ne: migrationName},
'auth.timestamps.loggedin': {$gt: new Date('2010-01-24')},
};
if (lastId) {
query._id = {
$gt: lastId,
};
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 250,
})
.then(updateUsers)
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
module.exports = processUsers;

View File

@@ -0,0 +1,149 @@
const UserNotification = require('../website/server/models/userNotification').model;
const content = require('../website/common/script/content/index');
const migrationName = '20180125_migrations-v2';
const authorName = 'paglias'; // in case script author needs to know when their ...
const authorUuid = 'ed4c688c-6652-4a92-9d03-a5a79844174a'; // ... own data is done
/*
* Migrate to new notifications system
*/
const monk = require('monk');
const connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
const dbUsers = monk(connectionString).get('users', { castIds: false });
const progressCount = 1000;
let count = 0;
function updateUser (user) {
count++;
const notifications = [];
// UNALLOCATED_STATS_POINTS skipped because added on each save
// NEW_STUFF skipped because it's a new type
// GROUP_TASK_NEEDS_WORK because it's a new type
// NEW_INBOX_MESSAGE not implemented yet
// NEW_MYSTERY_ITEMS
const mysteryItems = user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems;
if (Array.isArray(mysteryItems) && mysteryItems.length > 0) {
const newMysteryNotif = new UserNotification({
type: 'NEW_MYSTERY_ITEMS',
data: {
items: mysteryItems,
},
}).toJSON();
notifications.push(newMysteryNotif);
}
// CARD_RECEIVED
Object.keys(content.cardTypes).forEach(cardType => {
const existingCards = user.items.special[`${cardType}Received`] || [];
existingCards.forEach(sender => {
const newNotif = new UserNotification({
type: 'CARD_RECEIVED',
data: {
card: cardType,
from: {
// id is missing in old notifications
name: sender,
},
},
}).toJSON();
notifications.push(newNotif);
});
});
// NEW_CHAT_MESSAGE
Object.keys(user.newMessages).forEach(groupId => {
const existingNotif = user.newMessages[groupId];
if (existingNotif) {
const newNotif = new UserNotification({
type: 'NEW_CHAT_MESSAGE',
data: {
group: {
id: groupId,
name: existingNotif.name,
},
},
}).toJSON();
notifications.push(newNotif);
}
});
dbUsers.update({_id: user._id}, {
$push: {notifications: { $each: notifications } },
$set: {migration: migrationName},
});
if (count % progressCount === 0) console.warn(`${count } ${ user._id}`);
if (user._id === authorUuid) console.warn(`${authorName } processed`);
}
function exiting (code, msg) {
code = code || 0; // 0 = success
if (code && !msg) {
msg = 'ERROR!';
}
if (msg) {
if (code) {
console.error(msg);
} else {
console.log(msg);
}
}
process.exit(code);
}
function displayData () {
console.warn(`\n${ count } users processed\n`);
return exiting(0);
}
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users found and modified.');
displayData();
return;
}
const userPromises = users.map(updateUser);
const lastUser = users[users.length - 1];
return Promise.all(userPromises)
.then(() => {
processUsers(lastUser._id);
});
}
function processUsers (lastId) {
// specify a query to limit the affected users (empty for all users):
const query = {
migration: {$ne: migrationName},
'auth.timestamps.loggedin': {$gt: new Date('2010-01-24')},
};
if (lastId) {
query._id = {
$gt: lastId,
};
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 250,
})
.then(updateUsers)
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
module.exports = processUsers;

View File

@@ -17,5 +17,5 @@ function setUpServer () {
setUpServer(); setUpServer();
// Replace this with your migration // Replace this with your migration
const processUsers = require('./tasks/tasks-set-everyX'); const processUsers = require('./20180125_clean_new_notifications.js');
processUsers(); processUsers();

View File

@@ -1,10 +1,23 @@
var UserNotification = require('../website/server/models/userNotification').model
var _id = ''; var _id = '';
var items = ['back_mystery_201801','headAccessory_mystery_201801']
var update = { var update = {
$addToSet: { $addToSet: {
'purchased.plan.mysteryItems':{ 'purchased.plan.mysteryItems':{
$each:['back_mystery_201801','headAccessory_mystery_201801'] $each: items,
}
} }
},
$push: {
notifications: (new UserNotification({
type: 'NEW_MYSTERY_ITEMS',
data: {
items: items,
},
})).toJSON(),
},
}; };
/*var update = { /*var update = {

632
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@
"method-override": "^2.3.5", "method-override": "^2.3.5",
"moment": "^2.13.0", "moment": "^2.13.0",
"moment-recur": "git://github.com/habitrpg/moment-recur.git#f147ef27bbc26ca67638385f3db4a44084c76626", "moment-recur": "git://github.com/habitrpg/moment-recur.git#f147ef27bbc26ca67638385f3db4a44084c76626",
"mongoose": "~4.8.6", "mongoose": "^4.8.6",
"mongoose-id-autoinc": "~2013.7.14-4", "mongoose-id-autoinc": "~2013.7.14-4",
"morgan": "^1.7.0", "morgan": "^1.7.0",
"nconf": "~0.8.2", "nconf": "~0.8.2",

View File

@@ -426,6 +426,9 @@ describe('POST /chat', () => {
expect(message.message.id).to.exist; expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist;
expect(memberWithNotification.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupWithChat._id;
})).to.exist;
}); });
it('notifies other users of new messages for a party', async () => { it('notifies other users of new messages for a party', async () => {
@@ -443,6 +446,9 @@ describe('POST /chat', () => {
expect(message.message.id).to.exist; expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; expect(memberWithNotification.newMessages[`${group._id}`]).to.exist;
expect(memberWithNotification.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === group._id;
})).to.exist;
}); });
context('Spam prevention', () => { context('Spam prevention', () => {

View File

@@ -24,10 +24,13 @@ describe('POST /groups/:id/chat/seen', () => {
}); });
it('clears new messages for a guild', async () => { it('clears new messages for a guild', async () => {
await guildMember.sync();
const initialNotifications = guildMember.notifications.length;
await guildMember.post(`/groups/${guild._id}/chat/seen`); await guildMember.post(`/groups/${guild._id}/chat/seen`);
let guildThatHasSeenChat = await guildMember.get('/user'); let guildThatHasSeenChat = await guildMember.get('/user');
expect(guildThatHasSeenChat.notifications.length).to.equal(initialNotifications - 1);
expect(guildThatHasSeenChat.newMessages).to.be.empty; expect(guildThatHasSeenChat.newMessages).to.be.empty;
}); });
}); });
@@ -53,10 +56,13 @@ describe('POST /groups/:id/chat/seen', () => {
}); });
it('clears new messages for a party', async () => { it('clears new messages for a party', async () => {
await partyMember.sync();
const initialNotifications = partyMember.notifications.length;
await partyMember.post(`/groups/${party._id}/chat/seen`); await partyMember.post(`/groups/${party._id}/chat/seen`);
let partyMemberThatHasSeenChat = await partyMember.get('/user'); let partyMemberThatHasSeenChat = await partyMember.get('/user');
expect(partyMemberThatHasSeenChat.notifications.length).to.equal(initialNotifications - 1);
expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; expect(partyMemberThatHasSeenChat.newMessages).to.be.empty;
}); });
}); });

View File

@@ -70,13 +70,21 @@ describe('POST /groups/:groupId/leave', () => {
it('removes new messages for that group from user', async () => { it('removes new messages for that group from user', async () => {
await member.post(`/groups/${groupToLeave._id}/chat`, { message: 'Some message' }); await member.post(`/groups/${groupToLeave._id}/chat`, { message: 'Some message' });
await sleep(0.5);
await leader.sync(); await leader.sync();
expect(leader.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupToLeave._id;
})).to.exist;
expect(leader.newMessages[groupToLeave._id]).to.not.be.empty; expect(leader.newMessages[groupToLeave._id]).to.not.be.empty;
await leader.post(`/groups/${groupToLeave._id}/leave`); await leader.post(`/groups/${groupToLeave._id}/leave`);
await leader.sync(); await leader.sync();
expect(leader.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupToLeave._id;
})).to.not.exist;
expect(leader.newMessages[groupToLeave._id]).to.be.empty; expect(leader.newMessages[groupToLeave._id]).to.be.empty;
}); });

View File

@@ -2,6 +2,7 @@ import {
generateUser, generateUser,
createAndPopulateGroup, createAndPopulateGroup,
translate as t, translate as t,
sleep,
} from '../../../../helpers/api-v3-integration.helper'; } from '../../../../helpers/api-v3-integration.helper';
import * as email from '../../../../../website/server/libs/email'; import * as email from '../../../../../website/server/libs/email';
@@ -188,13 +189,20 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => {
it('removes new messages from a member who is removed', async () => { it('removes new messages from a member who is removed', async () => {
await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some message' }); await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some message' });
await sleep(0.5);
await removedMember.sync(); await removedMember.sync();
expect(removedMember.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === party._id;
})).to.exist;
expect(removedMember.newMessages[party._id]).to.not.be.empty; expect(removedMember.newMessages[party._id]).to.not.be.empty;
await partyLeader.post(`/groups/${party._id}/removeMember/${removedMember._id}`); await partyLeader.post(`/groups/${party._id}/removeMember/${removedMember._id}`);
await removedMember.sync(); await removedMember.sync();
expect(removedMember.notifications.find(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === party._id;
})).to.not.exist;
expect(removedMember.newMessages[party._id]).to.be.empty; expect(removedMember.newMessages[party._id]).to.be.empty;
}); });

View File

@@ -110,6 +110,7 @@ describe('Post /groups/:groupId/invite', () => {
id: group._id, id: group._id,
name: groupName, name: groupName,
inviter: inviter._id, inviter: inviter._id,
publicGuild: false,
}]); }]);
await expect(userToInvite.get('/user')) await expect(userToInvite.get('/user'))
@@ -127,11 +128,13 @@ describe('Post /groups/:groupId/invite', () => {
id: group._id, id: group._id,
name: groupName, name: groupName,
inviter: inviter._id, inviter: inviter._id,
publicGuild: false,
}, },
{ {
id: group._id, id: group._id,
name: groupName, name: groupName,
inviter: inviter._id, inviter: inviter._id,
publicGuild: false,
}, },
]); ]);

View File

@@ -98,6 +98,7 @@ describe('POST /members/send-private-message', () => {
it('sends a private message to a user', async () => { it('sends a private message to a user', async () => {
let receiver = await generateUser(); let receiver = await generateUser();
// const initialNotifications = receiver.notifications.length;
await userToSendMessage.post('/members/send-private-message', { await userToSendMessage.post('/members/send-private-message', {
message: messageToSend, message: messageToSend,
@@ -115,10 +116,44 @@ describe('POST /members/send-private-message', () => {
return message.uuid === receiver._id && message.text === messageToSend; return message.uuid === receiver._id && message.text === messageToSend;
}); });
// @TODO waiting for mobile support
// expect(updatedReceiver.notifications.length).to.equal(initialNotifications + 1);
// const notification = updatedReceiver.notifications[updatedReceiver.notifications.length - 1];
// expect(notification.type).to.equal('NEW_INBOX_MESSAGE');
// expect(notification.data.messageId).to.equal(sendersMessageInReceiversInbox.id);
// expect(notification.data.excerpt).to.equal(messageToSend);
// expect(notification.data.sender.id).to.equal(updatedSender._id);
// expect(notification.data.sender.name).to.equal(updatedSender.profile.name);
expect(sendersMessageInReceiversInbox).to.exist; expect(sendersMessageInReceiversInbox).to.exist;
expect(sendersMessageInSendersInbox).to.exist; expect(sendersMessageInSendersInbox).to.exist;
}); });
// @TODO waiting for mobile support
xit('creates a notification with an excerpt if the message is too long', async () => {
let receiver = await generateUser();
let longerMessageToSend = 'A very long message, that for sure exceeds the limit of 100 chars for the excerpt that we set to 100 chars';
let messageExcerpt = `${longerMessageToSend.substring(0, 100)}...`;
await userToSendMessage.post('/members/send-private-message', {
message: longerMessageToSend,
toUserId: receiver._id,
});
let updatedReceiver = await receiver.get('/user');
let sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => {
return message.uuid === userToSendMessage._id && message.text === longerMessageToSend;
});
const notification = updatedReceiver.notifications[updatedReceiver.notifications.length - 1];
expect(notification.type).to.equal('NEW_INBOX_MESSAGE');
expect(notification.data.messageId).to.equal(sendersMessageInReceiversInbox.id);
expect(notification.data.excerpt).to.equal(messageExcerpt);
});
it('allows admin to send when sender has blocked the admin', async () => { it('allows admin to send when sender has blocked the admin', async () => {
userToSendMessage = await generateUser({ userToSendMessage = await generateUser({
'contributor.admin': 1, 'contributor.admin': 1,

View File

@@ -0,0 +1,16 @@
import {
requester,
} from '../../../../helpers/api-v3-integration.helper';
describe('GET /news', () => {
let api;
beforeEach(async () => {
api = requester();
});
it('returns the latest news in html format, does not require authentication', async () => {
const res = await api.get('/news');
expect(res).to.be.a.string;
});
});

View File

@@ -0,0 +1,42 @@
import {
generateUser,
} from '../../../../helpers/api-v3-integration.helper';
describe('POST /news/tell-me-later', () => {
let user;
beforeEach(async () => {
user = await generateUser({
'flags.newStuff': true,
});
});
it('marks new stuff as read and adds notification', async () => {
expect(user.flags.newStuff).to.equal(true);
const initialNotifications = user.notifications.length;
await user.post('/news/tell-me-later');
await user.sync();
expect(user.flags.newStuff).to.equal(false);
expect(user.notifications.length).to.equal(initialNotifications + 1);
const notification = user.notifications[user.notifications.length - 1];
expect(notification.type).to.equal('NEW_STUFF');
// should be marked as seen by default so it's not counted in the number of notifications
expect(notification.seen).to.equal(true);
expect(notification.data.title).to.be.a.string;
});
it('never adds two notifications', async () => {
const initialNotifications = user.notifications.length;
await user.post('/news/tell-me-later');
await user.post('/news/tell-me-later');
await user.sync();
expect(user.notifications.length).to.equal(initialNotifications + 1);
});
});

View File

@@ -47,6 +47,7 @@ describe('POST /notifications/:notificationId/read', () => {
id: id2, id: id2,
type: 'LOGIN_INCENTIVE', type: 'LOGIN_INCENTIVE',
data: {}, data: {},
seen: false,
}]); }]);
await user.sync(); await user.sync();

View File

@@ -0,0 +1,59 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/:notificationId/see', () => {
let user;
before(async () => {
user = await generateUser();
});
it('errors when notification is not found', async () => {
let dummyId = generateUUID();
await expect(user.post(`/notifications/${dummyId}/see`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageNotificationNotFound'),
});
});
it('mark a notification as seen', async () => {
expect(user.notifications.length).to.equal(0);
const id = generateUUID();
const id2 = generateUUID();
await user.update({
notifications: [{
id,
type: 'DROPS_ENABLED',
data: {},
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
}],
});
const userObj = await user.get('/user'); // so we can check that defaults have been applied
expect(userObj.notifications.length).to.equal(2);
expect(userObj.notifications[0].seen).to.equal(false);
const res = await user.post(`/notifications/${id}/see`);
expect(res).to.deep.equal({
id,
type: 'DROPS_ENABLED',
data: {},
seen: true,
});
await user.sync();
expect(user.notifications.length).to.equal(2);
expect(user.notifications[0].id).to.equal(id);
expect(user.notifications[0].seen).to.equal(true);
});
});

View File

@@ -4,7 +4,7 @@ import {
} from '../../../../helpers/api-v3-integration.helper'; } from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/:notificationId/read', () => { describe('POST /notifications/read', () => {
let user; let user;
before(async () => { before(async () => {
@@ -57,6 +57,7 @@ describe('POST /notifications/:notificationId/read', () => {
id: id2, id: id2,
type: 'LOGIN_INCENTIVE', type: 'LOGIN_INCENTIVE',
data: {}, data: {},
seen: false,
}]); }]);
await user.sync(); await user.sync();

View File

@@ -0,0 +1,88 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /notifications/see', () => {
let user;
before(async () => {
user = await generateUser();
});
it('errors when notification is not found', async () => {
let dummyId = generateUUID();
await expect(user.post('/notifications/see', {
notificationIds: [dummyId],
})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageNotificationNotFound'),
});
});
it('mark multiple notifications as seen', async () => {
expect(user.notifications.length).to.equal(0);
const id = generateUUID();
const id2 = generateUUID();
const id3 = generateUUID();
await user.update({
notifications: [{
id,
type: 'DROPS_ENABLED',
data: {},
seen: false,
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}, {
id: id3,
type: 'CRON',
data: {},
seen: false,
}],
});
await user.sync();
expect(user.notifications.length).to.equal(3);
const res = await user.post('/notifications/see', {
notificationIds: [id, id3],
});
expect(res).to.deep.equal([
{
id,
type: 'DROPS_ENABLED',
data: {},
seen: true,
}, {
id: id2,
type: 'LOGIN_INCENTIVE',
data: {},
seen: false,
}, {
id: id3,
type: 'CRON',
data: {},
seen: true,
}]);
await user.sync();
expect(user.notifications.length).to.equal(3);
expect(user.notifications[0].id).to.equal(id);
expect(user.notifications[0].seen).to.equal(true);
expect(user.notifications[1].id).to.equal(id2);
expect(user.notifications[1].seen).to.equal(false);
expect(user.notifications[2].id).to.equal(id3);
expect(user.notifications[2].seen).to.equal(true);
});
});

View File

@@ -0,0 +1,189 @@
import {
createAndPopulateGroup,
translate as t,
} from '../../../../../helpers/api-integration/v3';
import { find } from 'lodash';
describe('POST /tasks/:id/needs-work/:userId', () => {
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
}
beforeEach(async () => {
let {group, members, groupLeader} = await createAndPopulateGroup({
groupDetails: {
name: 'Test Guild',
type: 'guild',
},
members: 2,
});
guild = group;
user = groupLeader;
member = members[0];
member2 = members[1];
task = await user.post(`/tasks/group/${guild._id}`, {
text: 'test todo',
type: 'todo',
requiresApproval: true,
});
});
it('errors when user is not assigned', async () => {
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
it('errors when user is not the group leader', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);
await expect(member.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('onlyGroupLeaderCanEditTasks'),
});
});
it('marks as task as needing more work', async () => {
const initialNotifications = member.notifications.length;
await user.post(`/tasks/${task._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
// score task to require approval
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
await user.post(`/tasks/${task._id}/needs-work/${member._id}`);
[memberTasks] = await Promise.all([member.get('/tasks/user'), member.sync()]);
syncedTask = find(memberTasks, findAssignedTask);
// Check that the notification approval request has been removed
expect(syncedTask.group.approval.requested).to.equal(false);
expect(syncedTask.group.approval.requestedDate).to.equal(undefined);
// Check that the notification is correct
expect(member.notifications.length).to.equal(initialNotifications + 1);
const notification = member.notifications[member.notifications.length - 1];
expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK');
const taskText = syncedTask.text;
const managerName = user.profile.name;
expect(notification.data.message).to.equal(t('taskNeedsWork', {taskText, managerName}));
expect(notification.data.task.id).to.equal(syncedTask._id);
expect(notification.data.task.text).to.equal(taskText);
expect(notification.data.group.id).to.equal(syncedTask.group.id);
expect(notification.data.group.name).to.equal(guild.name);
expect(notification.data.manager.id).to.equal(user._id);
expect(notification.data.manager.name).to.equal(managerName);
// Check that the managers' GROUP_TASK_APPROVAL notifications have been removed
await user.sync();
expect(user.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
});
it('allows a manager to mark a task as needing work', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
// score task to require approval
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
const initialNotifications = member.notifications.length;
await member2.post(`/tasks/${task._id}/needs-work/${member._id}`);
[memberTasks] = await Promise.all([member.get('/tasks/user'), member.sync()]);
syncedTask = find(memberTasks, findAssignedTask);
// Check that the notification approval request has been removed
expect(syncedTask.group.approval.requested).to.equal(false);
expect(syncedTask.group.approval.requestedDate).to.equal(undefined);
expect(member.notifications.length).to.equal(initialNotifications + 1);
const notification = member.notifications[member.notifications.length - 1];
expect(notification.type).to.equal('GROUP_TASK_NEEDS_WORK');
const taskText = syncedTask.text;
const managerName = member2.profile.name;
expect(notification.data.message).to.equal(t('taskNeedsWork', {taskText, managerName}));
expect(notification.data.task.id).to.equal(syncedTask._id);
expect(notification.data.task.text).to.equal(taskText);
expect(notification.data.group.id).to.equal(syncedTask.group.id);
expect(notification.data.group.name).to.equal(guild.name);
expect(notification.data.manager.id).to.equal(member2._id);
expect(notification.data.manager.name).to.equal(managerName);
// Check that the managers' GROUP_TASK_APPROVAL notifications have been removed
await Promise.all([user.sync(), member2.sync()]);
expect(user.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
expect(member2.notifications.find(n => {
n.data.taskId === syncedTask._id && n.type === 'GROUP_TASK_APPROVAL';
})).to.equal(undefined);
});
it('prevents marking a task as needing work if it was already approved', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
await member2.post(`/tasks/${task._id}/approve/${member._id}`);
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('canOnlyApproveTaskOnce'),
});
});
it('prevents marking a task as needing work if it is not waiting for approval', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);
await expect(user.post(`/tasks/${task._id}/needs-work/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalWasNotRequested'),
});
});
});

View File

@@ -25,6 +25,7 @@ describe('GET /user/anonymized', () => {
'achievements.challenges': 'some', 'achievements.challenges': 'some',
'inbox.messages': [{ text: 'some text' }], 'inbox.messages': [{ text: 'some text' }],
tags: [{ name: 'some name', challenge: 'some challenge' }], tags: [{ name: 'some name', challenge: 'some challenge' }],
notifications: [],
}); });
await generateHabit({ userId: user._id }); await generateHabit({ userId: user._id });
@@ -65,6 +66,7 @@ describe('GET /user/anonymized', () => {
expect(returnedUser.stats.toNextLevel).to.eql(common.tnl(user.stats.lvl)); expect(returnedUser.stats.toNextLevel).to.eql(common.tnl(user.stats.lvl));
expect(returnedUser.stats.maxMP).to.eql(30); // TODO why 30? expect(returnedUser.stats.maxMP).to.eql(30); // TODO why 30?
expect(returnedUser.newMessages).to.not.exist; expect(returnedUser.newMessages).to.not.exist;
expect(returnedUser.notifications).to.not.exist;
expect(returnedUser.profile).to.not.exist; expect(returnedUser.profile).to.not.exist;
expect(returnedUser.purchased.plan).to.not.exist; expect(returnedUser.purchased.plan).to.not.exist;
expect(returnedUser.contributor).to.not.exist; expect(returnedUser.contributor).to.not.exist;

View File

@@ -13,15 +13,20 @@ describe('POST /user/open-mystery-item', () => {
beforeEach(async () => { beforeEach(async () => {
user = await generateUser({ user = await generateUser({
'purchased.plan.mysteryItems': [mysteryItemKey], 'purchased.plan.mysteryItems': [mysteryItemKey],
notifications: [
{type: 'NEW_MYSTERY_ITEMS', data: { items: [mysteryItemKey] }},
],
}); });
}); });
// More tests in common code unit tests // More tests in common code unit tests
it('opens a mystery item', async () => { it('opens a mystery item', async () => {
expect(user.notifications.length).to.equal(1);
let response = await user.post('/user/open-mystery-item'); let response = await user.post('/user/open-mystery-item');
await user.sync(); await user.sync();
expect(user.notifications.length).to.equal(0);
expect(user.items.gear.owned[mysteryItemKey]).to.be.true; expect(user.items.gear.owned[mysteryItemKey]).to.be.true;
expect(response.message).to.equal(t('mysteryItemOpened')); expect(response.message).to.equal(t('mysteryItemOpened'));
expect(response.data.key).to.eql(mysteryItemKey); expect(response.data.key).to.eql(mysteryItemKey);

View File

@@ -26,13 +26,21 @@ describe('POST /user/read-card/:cardType', () => {
await user.update({ await user.update({
'items.special.greetingReceived': [true], 'items.special.greetingReceived': [true],
'flags.cardReceived': true, 'flags.cardReceived': true,
notifications: [{
type: 'CARD_RECEIVED',
data: {card: cardType},
}],
}); });
await user.sync();
expect(user.notifications.length).to.equal(1);
let response = await user.post(`/user/read-card/${cardType}`); let response = await user.post(`/user/read-card/${cardType}`);
await user.sync(); await user.sync();
expect(response.message).to.equal(t('readCard', {cardType})); expect(response.message).to.equal(t('readCard', {cardType}));
expect(user.items.special[`${cardType}Received`]).to.be.empty; expect(user.items.special[`${cardType}Received`]).to.be.empty;
expect(user.flags.cardReceived).to.be.false; expect(user.flags.cardReceived).to.be.false;
expect(user.notifications.length).to.equal(0);
}); });
}); });

View File

@@ -420,11 +420,16 @@ describe('payments/index', () => {
data = { paymentMethod: 'PaymentMethod', user, sub: { key: 'basic_3mo' } }; data = { paymentMethod: 'PaymentMethod', user, sub: { key: 'basic_3mo' } };
const oldNotificationsCount = user.notifications.length;
await api.createSubscription(data); await api.createSubscription(data);
expect(user.notifications.find(n => n.type === 'NEW_MYSTERY_ITEMS')).to.not.be.undefined;
expect(user.purchased.plan.mysteryItems).to.have.a.lengthOf(2); expect(user.purchased.plan.mysteryItems).to.have.a.lengthOf(2);
expect(user.purchased.plan.mysteryItems).to.include('armor_mystery_201605'); expect(user.purchased.plan.mysteryItems).to.include('armor_mystery_201605');
expect(user.purchased.plan.mysteryItems).to.include('head_mystery_201605'); expect(user.purchased.plan.mysteryItems).to.include('head_mystery_201605');
expect(user.notifications.length).to.equal(oldNotificationsCount + 1);
expect(user.notifications[0].type).to.equal('NEW_MYSTERY_ITEMS');
fakeClock.restore(); fakeClock.restore();
}); });

View File

@@ -106,6 +106,7 @@ describe('response middleware', () => {
type: notification.type, type: notification.type,
id: notification.id, id: notification.id,
data: {}, data: {},
seen: false,
}, },
], ],
userV: res.locals.user._v, userV: res.locals.user._v,

View File

@@ -1011,13 +1011,6 @@ describe('Group Model', () => {
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
'party._id': party._id, 'party._id': party._id,
_id: { $ne: '' }, _id: { $ne: '' },
}, {
$set: {
[`newMessages.${party._id}`]: {
name: party.name,
value: true,
},
},
}); });
}); });
@@ -1032,13 +1025,6 @@ describe('Group Model', () => {
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
guilds: group._id, guilds: group._id,
_id: { $ne: '' }, _id: { $ne: '' },
}, {
$set: {
[`newMessages.${group._id}`]: {
name: group.name,
value: true,
},
},
}); });
}); });
@@ -1049,13 +1035,6 @@ describe('Group Model', () => {
expect(User.update).to.be.calledWithMatch({ expect(User.update).to.be.calledWithMatch({
'party._id': party._id, 'party._id': party._id,
_id: { $ne: 'user-id' }, _id: { $ne: 'user-id' },
}, {
$set: {
[`newMessages.${party._id}`]: {
name: party.name,
value: true,
},
},
}); });
}); });

View File

@@ -58,21 +58,23 @@ describe('User Model', () => {
let userToJSON = user.toJSON(); let userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({}); expect(userToJSON.notifications[0].data).to.eql({});
expect(userToJSON.notifications[0].seen).to.eql(false);
}); });
it('can add notifications with data', () => { it('can add notifications with data and already marked as seen', () => {
let user = new User(); let user = new User();
user.addNotification('CRON', {field: 1}); user.addNotification('CRON', {field: 1}, true);
let userToJSON = user.toJSON(); let userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({field: 1}); expect(userToJSON.notifications[0].data).to.eql({field: 1});
expect(userToJSON.notifications[0].seen).to.eql(true);
}); });
context('static push method', () => { context('static push method', () => {
@@ -86,7 +88,7 @@ describe('User Model', () => {
let userToJSON = user.toJSON(); let userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({}); expect(userToJSON.notifications[0].data).to.eql({});
}); });
@@ -96,6 +98,7 @@ describe('User Model', () => {
await user.save(); await user.save();
expect(User.pushNotification({_id: user._id}, 'BAD_TYPE')).to.eventually.be.rejected; expect(User.pushNotification({_id: user._id}, 'BAD_TYPE')).to.eventually.be.rejected;
expect(User.pushNotification({_id: user._id}, 'CRON', null, 'INVALID_SEEN')).to.eventually.be.rejected;
}); });
it('adds notifications without data for all given users via static method', async() => { it('adds notifications without data for all given users via static method', async() => {
@@ -109,41 +112,45 @@ describe('User Model', () => {
let userToJSON = user.toJSON(); let userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({}); expect(userToJSON.notifications[0].data).to.eql({});
expect(userToJSON.notifications[0].seen).to.eql(false);
user = await User.findOne({_id: otherUser._id}).exec(); user = await User.findOne({_id: otherUser._id}).exec();
userToJSON = user.toJSON(); userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({}); expect(userToJSON.notifications[0].data).to.eql({});
expect(userToJSON.notifications[0].seen).to.eql(false);
}); });
it('adds notifications with data for all given users via static method', async() => { it('adds notifications with data and seen status for all given users via static method', async() => {
let user = new User(); let user = new User();
let otherUser = new User(); let otherUser = new User();
await Bluebird.all([user.save(), otherUser.save()]); await Bluebird.all([user.save(), otherUser.save()]);
await User.pushNotification({_id: {$in: [user._id, otherUser._id]}}, 'CRON', {field: 1}); await User.pushNotification({_id: {$in: [user._id, otherUser._id]}}, 'CRON', {field: 1}, true);
user = await User.findOne({_id: user._id}).exec(); user = await User.findOne({_id: user._id}).exec();
let userToJSON = user.toJSON(); let userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({field: 1}); expect(userToJSON.notifications[0].data).to.eql({field: 1});
expect(userToJSON.notifications[0].seen).to.eql(true);
user = await User.findOne({_id: otherUser._id}).exec(); user = await User.findOne({_id: otherUser._id}).exec();
userToJSON = user.toJSON(); userToJSON = user.toJSON();
expect(user.notifications.length).to.equal(1); expect(user.notifications.length).to.equal(1);
expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type']); expect(userToJSON.notifications[0]).to.have.all.keys(['data', 'id', 'type', 'seen']);
expect(userToJSON.notifications[0].type).to.equal('CRON'); expect(userToJSON.notifications[0].type).to.equal('CRON');
expect(userToJSON.notifications[0].data).to.eql({field: 1}); expect(userToJSON.notifications[0].data).to.eql({field: 1});
expect(userToJSON.notifications[0].seen).to.eql(true);
}); });
}); });
}); });
@@ -322,6 +329,65 @@ describe('User Model', () => {
user = await user.save(); user = await user.save();
expect(user.achievements.beastMaster).to.not.equal(true); expect(user.achievements.beastMaster).to.not.equal(true);
}); });
context('manage unallocated stats points notifications', () => {
it('doesn\'t add a notification if there are no points to allocate', async () => {
let user = new User();
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
user.stats.points = 0;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount);
});
it('removes a notification if there are no more points to allocate', async () => {
let user = new User();
user.stats.points = 9;
user = await user.save(); // necessary for user.isSelected to work correctly
expect(user.notifications[0].type).to.equal('UNALLOCATED_STATS_POINTS');
const oldNotificationsCount = user.notifications.length;
user.stats.points = 0;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount - 1);
});
it('adds a notification if there are points to allocate', async () => {
let user = new User();
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
user.stats.points = 9;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount + 1);
expect(user.notifications[0].type).to.equal('UNALLOCATED_STATS_POINTS');
expect(user.notifications[0].data.points).to.equal(9);
});
it('adds a notification if the points to allocate have changed', async () => {
let user = new User();
user.stats.points = 9;
user = await user.save(); // necessary for user.isSelected to work correctly
const oldNotificationsCount = user.notifications.length;
const oldNotificationsUUID = user.notifications[0].id;
expect(user.notifications[0].type).to.equal('UNALLOCATED_STATS_POINTS');
expect(user.notifications[0].data.points).to.equal(9);
user.stats.points = 11;
user = await user.save();
expect(user.notifications.length).to.equal(oldNotificationsCount);
expect(user.notifications[0].type).to.equal('UNALLOCATED_STATS_POINTS');
expect(user.notifications[0].data.points).to.equal(11);
expect(user.notifications[0].id).to.not.equal(oldNotificationsUUID);
});
});
}); });
context('days missed', () => { context('days missed', () => {

View File

@@ -29,11 +29,14 @@ describe('shared.ops.openMysteryItem', () => {
let mysteryItemKey = 'eyewear_special_summerRogue'; let mysteryItemKey = 'eyewear_special_summerRogue';
user.purchased.plan.mysteryItems = [mysteryItemKey]; user.purchased.plan.mysteryItems = [mysteryItemKey];
user.notifications.push({type: 'NEW_MYSTERY_ITEMS', data: {items: [mysteryItemKey]}});
expect(user.notifications.length).to.equal(1);
let [data, message] = openMysteryItem(user); let [data, message] = openMysteryItem(user);
expect(user.items.gear.owned[mysteryItemKey]).to.be.true; expect(user.items.gear.owned[mysteryItemKey]).to.be.true;
expect(message).to.equal(i18n.t('mysteryItemOpened')); expect(message).to.equal(i18n.t('mysteryItemOpened'));
expect(data).to.eql(content.gear.flat[mysteryItemKey]); expect(data).to.eql(content.gear.flat[mysteryItemKey]);
expect(user.notifications.length).to.equal(0);
}); });
}); });

View File

@@ -39,10 +39,17 @@ describe('shared.ops.readCard', () => {
}); });
it('reads a card', () => { it('reads a card', () => {
user.notifications.push({
type: 'CARD_RECEIVED',
data: {card: cardType},
});
const initialNotificationNuber = user.notifications.length;
let [, message] = readCard(user, {params: {cardType: 'greeting'}}); let [, message] = readCard(user, {params: {cardType: 'greeting'}});
expect(message).to.equal(i18n.t('readCard', {cardType})); expect(message).to.equal(i18n.t('readCard', {cardType}));
expect(user.items.special[`${cardType}Received`]).to.be.empty; expect(user.items.special[`${cardType}Received`]).to.be.empty;
expect(user.flags.cardReceived).to.be.false; expect(user.flags.cardReceived).to.be.false;
expect(user.notifications.length).to.equal(initialNotificationNuber - 1);
}); });
}); });

View File

@@ -137,3 +137,9 @@
border: 0; border: 0;
box-shadow: none; box-shadow: none;
} }
.btn-small {
font-size: 12px;
line-height: 1.33;
padding: 4px 8px;
}

View File

@@ -45,6 +45,15 @@
background-color: rgba(#d5c8ff, 0.32); background-color: rgba(#d5c8ff, 0.32);
color: $purple-200; color: $purple-200;
} }
&.dropdown-inactive {
cursor: default;
&:active, &:hover, &.active {
background-color: inherit;
color: inherit;
}
}
} }
.dropdown + .dropdown { .dropdown + .dropdown {

View File

@@ -23,6 +23,11 @@
height: 16px; height: 16px;
} }
.icon-12 {
width: 12px;
height: 12px;
}
.icon-10 { .icon-10 {
width: 10px; width: 10px;
height: 10px; height: 10px;

View File

@@ -9,23 +9,18 @@
background: $purple-300; background: $purple-300;
color: $white; color: $white;
} }
} }
span.badge.badge-pill.badge-item.badge-svg:not(.item-selected-badge) { span.badge.badge-pill.badge-item.badge-svg:not(.item-selected-badge) {
color: #a5a1ac; color: #a5a1ac;
} }
span.badge.badge-pill.badge-item.badge-svg.hide { span.badge.badge-pill.badge-item.badge-svg.hide {
display: none; display: none;
} }
.item:hover { .item:hover {
span.badge.badge-pill.badge-item.badge-svg.hide { span.badge.badge-pill.badge-item.badge-svg.hide {
display: block; display: block;
} }
} }
.icon-12 {
width: 12px;
height: 12px;
}

View File

@@ -1,26 +1,32 @@
@import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/colors.scss';
.container-fluid { .container-fluid.static-view {
margin: 5em 2em 0 2em; margin: 5em 2em 0 2em;
} }
h1, h2 { .static-view {
h1, h2 {
margin-top: 0.5em; margin-top: 0.5em;
color: $purple-200; color: $purple-200;
} }
h3, h4 { h3, h4 {
color: $purple-200; color: $purple-200;
} }
li, p { li, p {
font-size: 16px; font-size: 16px;
} }
.media img { .media img {
margin: 1em; margin: 1em;
} }
.strong { .strong {
font-weight: bold; font-weight: bold;
}
.center-block {
margin: 0 auto 1em auto;
}
} }

View File

@@ -15,8 +15,27 @@ body {
color: $gray-200; color: $gray-200;
} }
a { a, a:not([href]):not([tabindex]) {
cursor: pointer; cursor: pointer;
&.standard-link {
color: $blue-10;
&:hover, &:active, &:focus {
text-decoration: underline;
}
&[disabled="disabled"] {
color: $gray-300;
text-decoration: none;
cursor: default;
}
}
&.small-link {
font-size: 12px;
line-height: 1.33;
}
} }
// Headers // Headers

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="23" height="28" viewBox="0 0 23 28">
<g fill="none" fill-rule="evenodd">
<path fill="#50B5E9" d="M11.074 8.485l4.26-2.121-4.26-2.122L8.944 0l-2.13 4.242-4.258 2.122 4.259 2.12 2.13 4.243z"/>
<path fill="#9A62FF" d="M5.111 19.09l2.556-1.272-2.556-1.273L3.833 14l-1.277 2.545L0 17.818l2.556 1.273 1.277 2.545z"/>
<path fill="#FF6165" d="M12.778 25.455l2.555-1.273-2.555-1.273-1.278-2.545-1.278 2.545-2.555 1.273 2.555 1.273L11.5 28z"/>
<path fill="#FFB445" d="M19.593 15.697L23 14l-3.407-1.697-1.704-3.394-1.704 3.394L12.778 14l3.407 1.697 1.704 3.394z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="104" viewBox="0 0 256 104">
<g fill="none" fill-rule="evenodd">
<g opacity=".64" transform="translate(-44 -16)">
<rect width="346" height="136" rx="2"/>
<path fill="#3FDAA2" d="M232.359 84.179l3.074-1.341-2.917-1.655-1.341-3.075-1.655 2.918-3.075 1.34 2.918 1.656 1.34 3.074z" opacity=".96"/>
<path fill="#FF6165" d="M171.439 20.105l-.023 2.982 2.399-1.771 2.981.023-1.77-2.4.022-2.98-2.399 1.77-2.98-.022z" opacity=".84"/>
<path fill="#3FDAA2" d="M114.501 54.126l2.574.428-1.202-2.315.428-2.574-2.316 1.202-2.573-.427 1.202 2.315-.428 2.573z" opacity=".83"/>
<path fill="#50B5E9" d="M284.929 89.34l.173 6.333 4.962-3.939 6.334-.173-3.939-4.962-.173-6.334-4.962 3.939-6.334.173z" opacity=".73"/>
<path fill="#FFBE5D" d="M242.881 57.724l-3.984 5.397 6.708-.05 5.397 3.983-.05-6.708 3.983-5.397-6.708.051-5.397-3.984z" opacity=".82"/>
<path fill="#50B5E9" d="M125.165 110.829l-.589-4.433-3.193 3.13-4.433.59 3.13 3.193.59 4.433 3.193-3.13 4.433-.59z" opacity=".99"/>
<path fill="#50B5E9" d="M163.702 56.186l-3.901 5.91 7.068-.425 5.91 3.902-.425-7.069 3.901-5.909-7.068.425-5.909-3.902z"/>
<path fill="#FF6165" d="M206.14 107.367l3.404 2.9.278-4.463 2.9-3.404-4.463-.278-3.404-2.9-.278 4.463-2.9 3.404z" opacity=".84"/>
<path fill="#FF944C" d="M50.708 53.066l.62 3.675 2.568-2.7 3.675-.62-2.7-2.568-.62-3.675-2.568 2.7-3.675.62zM297.486 59.18l-.037-3.727-2.96 2.265-3.726.037 2.265 2.959.037 3.727 2.96-2.266 3.726-.037z" opacity=".93"/>
<path fill="#9A62FF" d="M95.481 86.952l5.13-.957-3.843-3.53-.957-5.128-3.53 3.842-5.128.957 3.843 3.53.956 5.128z" opacity=".99"/>
<path fill="#FFBE5D" d="M122.061 24.656l-.952 3.987 3.761-1.63 3.987.952-1.63-3.761.953-3.988-3.762 1.63-3.987-.952z"/>
<path fill="#3FDAA2" d="M49.863 86.74l4.692 1.207-1.849-4.478 1.208-4.692-4.478 1.849-4.692-1.208 1.849 4.478-1.208 4.692z" opacity=".96"/>
<path fill="#9A62FF" d="M226.63 30.447l5.026-1.4-4.135-3.18-1.4-5.027-3.181 4.136-5.026 1.4 4.135 3.18 1.4 5.027z" opacity=".99"/>
</g>
<path fill="#3FDAA2" d="M119.347 104c-2.031 0-3.971-.8-5.41-2.24L89 76.824l10.819-10.818 19.098 19.098L157.57 40l11.619 9.953-44.03 51.378a7.694 7.694 0 0 1-5.52 2.662c-.097.007-.195.007-.292.007"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,58 +1,58 @@
<template lang="pug"> <template lang="pug">
b-modal#new-stuff( b-modal#new-stuff(
v-if='user.flags.newStuff',
size='lg', size='lg',
:hide-header='true', :hide-header='true',
:hide-footer='true', :hide-footer='true',
) )
.modal-body .modal-body
new-stuff .static-view(v-html='html')
.modal-footer .modal-footer
a.btn.btn-info(href='http://habitica.wikia.com/wiki/Whats_New', target='_blank') {{ this.$t('newsArchive') }} a.btn.btn-info(href='http://habitica.wikia.com/wiki/Whats_New', target='_blank') {{ this.$t('newsArchive') }}
button.btn.btn-secondary(@click='close()') {{ this.$t('cool') }} button.btn.btn-secondary(@click='tellMeLater()') {{ this.$t('tellMeLater') }}
button.btn.btn-warning(@click='dismissAlert();') {{ this.$t('dismissAlert') }} button.btn.btn-warning(@click='dismissAlert();') {{ this.$t('dismissAlert') }}
</template> </template>
<style lang='scss' scoped> <style lang='scss'>
@import '~client/assets/scss/static.scss'; @import '~client/assets/scss/static.scss';
</style>
.modal-body { <style lang='scss' scoped>
.modal-body {
padding-top: 2em; padding-top: 2em;
} }
</style> </style>
<script> <script>
import axios from 'axios';
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
import markdown from 'client/directives/markdown';
import newStuff from 'client/components/static/newStuff';
export default { export default {
components: { data () {
newStuff, return {
html: '',
};
}, },
computed: { computed: {
...mapState({user: 'user.data'}), ...mapState({user: 'user.data'}),
}, },
directives: { async mounted () {
markdown,
},
mounted () {
this.$root.$on('bv::show::modal', async (modalId) => { this.$root.$on('bv::show::modal', async (modalId) => {
if (modalId !== 'new-stuff') return; if (modalId !== 'new-stuff') return;
// Request the lastest news, but not locally incase they don't refresh let response = await axios.get('/api/v3/news');
// let response = await axios.get('/static/new-stuff'); this.html = response.data.html;
}); });
}, },
destroyed () { destroyed () {
this.$root.$off('bv::show::modal'); this.$root.$off('bv::show::modal');
}, },
methods: { methods: {
close () { tellMeLater () {
this.$store.dispatch('user:newStuffLater');
this.$root.$emit('bv::hide::modal', 'new-stuff'); this.$root.$emit('bv::hide::modal', 'new-stuff');
}, },
dismissAlert () { dismissAlert () {
this.$store.dispatch('user:set', {'flags.newStuff': false}); this.$store.dispatch('user:set', {'flags.newStuff': false});
this.close(); this.$root.$emit('bv::hide::modal', 'new-stuff');
}, },
}, },
}; };

View File

@@ -87,21 +87,21 @@
.debug.float-left(v-if="!IS_PRODUCTION && isUserLoaded") .debug.float-left(v-if="!IS_PRODUCTION && isUserLoaded")
button.btn.btn-primary(@click="debugMenuShown = !debugMenuShown") Toggle Debug Menu button.btn.btn-primary(@click="debugMenuShown = !debugMenuShown") Toggle Debug Menu
.debug-group(v-if="debugMenuShown") .debug-group(v-if="debugMenuShown")
a.btn.btn-default(@click="setHealthLow()") Health = 1 a.btn.btn-secondary(@click="setHealthLow()") Health = 1
a.btn.btn-default(@click="addMissedDay(1)") +1 Missed Day a.btn.btn-secondary(@click="addMissedDay(1)") +1 Missed Day
a.btn.btn-default(@click="addMissedDay(2)") +2 Missed Days a.btn.btn-secondary(@click="addMissedDay(2)") +2 Missed Days
a.btn.btn-default(@click="addMissedDay(8)") +8 Missed Days a.btn.btn-secondary(@click="addMissedDay(8)") +8 Missed Days
a.btn.btn-default(@click="addMissedDay(32)") +32 Missed Days a.btn.btn-secondary(@click="addMissedDay(32)") +32 Missed Days
a.btn.btn-default(@click="addTenGems()") +10 Gems a.btn.btn-secondary(@click="addTenGems()") +10 Gems
a.btn.btn-default(@click="addHourglass()") +1 Mystic Hourglass a.btn.btn-secondary(@click="addHourglass()") +1 Mystic Hourglass
a.btn.btn-default(@click="addGold()") +500GP a.btn.btn-secondary(@click="addGold()") +500GP
a.btn.btn-default(@click="plusTenHealth()") + 10HP a.btn.btn-secondary(@click="plusTenHealth()") + 10HP
a.btn.btn-default(@click="addMana()") +MP a.btn.btn-secondary(@click="addMana()") +MP
a.btn.btn-default(@click="addLevelsAndGold()") +Exp +GP +MP a.btn.btn-secondary(@click="addLevelsAndGold()") +Exp +GP +MP
a.btn.btn-default(@click="addOneLevel()") +1 Level a.btn.btn-secondary(@click="addOneLevel()") +1 Level
a.btn.btn-default(@click="addQuestProgress()", tooltip="+1000 to boss quests. 300 items to collection quests") Quest Progress Up a.btn.btn-secondary(@click="addQuestProgress()", tooltip="+1000 to boss quests. 300 items to collection quests") Quest Progress Up
a.btn.btn-default(@click="makeAdmin()") Make Admin a.btn.btn-secondary(@click="makeAdmin()") Make Admin
a.btn.btn-default(@click="openModifyInventoryModal()") Modify Inventory a.btn.btn-secondary(@click="openModifyInventoryModal()") Modify Inventory
.col-12.col-md-2.text-center .col-12.col-md-2.text-center
.logo.svg-icon(v-html='icons.gryphon') .logo.svg-icon(v-html='icons.gryphon')
.col-12.col-md-5.text-right .col-12.col-md-5.text-right

View File

@@ -427,14 +427,9 @@ export default {
}, },
}, },
mounted () { mounted () {
if (this.isParty) this.searchId = 'party';
if (!this.searchId) this.searchId = this.groupId; if (!this.searchId) this.searchId = this.groupId;
this.load(); this.load();
if (this.user.newMessages[this.searchId]) {
this.$store.dispatch('chat:markChatSeen', {groupId: this.searchId});
this.$delete(this.user.newMessages, this.searchId);
}
}, },
beforeRouteUpdate (to, from, next) { beforeRouteUpdate (to, from, next) {
this.$set(this, 'searchId', to.params.groupId); this.$set(this, 'searchId', to.params.groupId);
@@ -462,12 +457,6 @@ export default {
}, },
methods: { methods: {
load () { load () {
if (this.isParty) {
this.searchId = 'party';
// @TODO: Set up from old client. Decide what we need and what we don't
// Check Desktop notifs
// Load invites
}
this.fetchGuild(); this.fetchGuild();
this.$root.$on('updatedGroup', group => { this.$root.$on('updatedGroup', group => {
@@ -550,6 +539,22 @@ export default {
const group = await this.$store.dispatch('guilds:getGroup', {groupId: this.searchId}); const group = await this.$store.dispatch('guilds:getGroup', {groupId: this.searchId});
this.$set(this, 'group', group); this.$set(this, 'group', group);
} }
const groupId = this.searchId === 'party' ? this.user.party._id : this.searchId;
if (this.hasUnreadMessages(groupId)) {
// Delay by 1sec to make sure it returns after other requests that don't have the notification marked as read
setTimeout(() => {
this.$store.dispatch('chat:markChatSeen', {groupId});
this.$delete(this.user.newMessages, groupId);
}, 1000);
}
},
hasUnreadMessages (groupId) {
if (this.user.newMessages[groupId]) return true;
return this.user.notifications.some(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupId;
});
}, },
deleteAllMessages () { deleteAllMessages () {
if (confirm(this.$t('confirmDeleteAllMessages'))) { if (confirm(this.$t('confirmDeleteAllMessages'))) {
@@ -575,8 +580,7 @@ export default {
if (this.group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) { if (this.group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return; return;
} }
await this.$store.dispatch('guilds:join', {guildId: this.group._id, type: 'myGuilds'}); await this.$store.dispatch('guilds:join', {groupId: this.group._id, type: 'guild'});
this.user.guilds.push(this.group._id);
}, },
clickLeave () { clickLeave () {
Analytics.track({ Analytics.track({
@@ -616,20 +620,6 @@ export default {
this.$store.state.upgradingGroup = this.group; this.$store.state.upgradingGroup = this.group;
this.$router.push('/group-plans'); this.$router.push('/group-plans');
}, },
// @TODO: Move to notificatin component
async leaveOldPartyAndJoinNewParty () {
let newPartyName = 'where does this come from';
if (!confirm(`Are you sure you want to delete your party and join${newPartyName}?`)) return;
let keepChallenges = 'remain-in-challenges';
await this.$store.dispatch('guilds:leave', {
groupId: this.group._id,
keep: false,
keepChallenges,
});
await this.$store.dispatch('guilds:join', {groupId: this.group._id});
},
clickStartQuest () { clickStartQuest () {
Analytics.track({ Analytics.track({
hitType: 'event', hitType: 'event',

View File

@@ -130,7 +130,6 @@ import moment from 'moment';
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
import groupUtilities from 'client/mixins/groupsUtilities'; import groupUtilities from 'client/mixins/groupsUtilities';
import markdown from 'client/directives/markdown'; import markdown from 'client/directives/markdown';
import findIndex from 'lodash/findIndex';
import gemIcon from 'assets/svg/gem.svg'; import gemIcon from 'assets/svg/gem.svg';
import goldGuildBadgeIcon from 'assets/svg/gold-guild-badge-large.svg'; import goldGuildBadgeIcon from 'assets/svg/gold-guild-badge-large.svg';
import silverGuildBadgeIcon from 'assets/svg/silver-guild-badge-large.svg'; import silverGuildBadgeIcon from 'assets/svg/silver-guild-badge-large.svg';
@@ -171,20 +170,12 @@ export default {
if (this.guild.cancelledPlan && !confirm(window.env.t('aboutToJoinCancelledGroupPlan'))) { if (this.guild.cancelledPlan && !confirm(window.env.t('aboutToJoinCancelledGroupPlan'))) {
return; return;
} }
await this.$store.dispatch('guilds:join', {guildId: this.guild._id, type: 'myGuilds'}); await this.$store.dispatch('guilds:join', {groupId: this.guild._id, type: 'guild'});
}, },
async leave () { async leave () {
// @TODO: ask about challenges when we add challenges // @TODO: ask about challenges when we add challenges
await this.$store.dispatch('guilds:leave', {groupId: this.guild._id, type: 'myGuilds'}); await this.$store.dispatch('guilds:leave', {groupId: this.guild._id, type: 'myGuilds'});
}, },
async reject (invitationToReject) {
// @TODO: This needs to be in the notifications where users will now accept invites
let index = findIndex(this.user.invitations.guilds, function findInviteIndex (invite) {
return invite.id === invitationToReject.id;
});
this.user.invitations.guilds = this.user.invitations.guilds.splice(0, index);
await this.$store.dispatch('guilds:rejectInvite', {guildId: invitationToReject.id});
},
}, },
}; };
</script> </script>

View File

@@ -57,16 +57,16 @@ div
a.dropdown-item(href="http://habitica.wikia.com/wiki/Habitica_Wiki", target='_blank') {{ $t('wiki') }} a.dropdown-item(href="http://habitica.wikia.com/wiki/Habitica_Wiki", target='_blank') {{ $t('wiki') }}
.user-menu.d-flex.align-items-center .user-menu.d-flex.align-items-center
.item-with-icon(v-if="userHourglasses > 0") .item-with-icon(v-if="userHourglasses > 0")
.svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')") .top-menu-icon.svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')")
span {{ userHourglasses }} span {{ userHourglasses }}
.item-with-icon .item-with-icon
.svg-icon.gem(v-html="icons.gem", @click='showBuyGemsModal("gems")', v-b-tooltip.hover.bottom="$t('gems')") .top-menu-icon.svg-icon.gem(v-html="icons.gem", @click='showBuyGemsModal("gems")', v-b-tooltip.hover.bottom="$t('gems')")
span {{userGems | roundBigNumber}} span {{userGems | roundBigNumber}}
.item-with-icon.gold .item-with-icon.gold
.svg-icon(v-html="icons.gold", v-b-tooltip.hover.bottom="$t('gold')") .top-menu-icon.svg-icon(v-html="icons.gold", v-b-tooltip.hover.bottom="$t('gold')")
span {{Math.floor(user.stats.gp * 100) / 100}} span {{Math.floor(user.stats.gp * 100) / 100}}
a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')") a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')")
.svg-icon(v-html="icons.sync") .top-menu-icon.svg-icon(v-html="icons.sync")
notification-menu.item-with-icon notification-menu.item-with-icon
user-dropdown.item-with-icon user-dropdown.item-with-icon
</template> </template>
@@ -236,11 +236,11 @@ div
margin-right: 24px; margin-right: 24px;
} }
&:hover /deep/ .svg-icon { &:hover /deep/ .top-menu-icon.svg-icon {
color: $white; color: $white;
} }
& /deep/ .svg-icon { & /deep/ .top-menu-icon.svg-icon {
color: $header-color; color: $header-color;
vertical-align: bottom; vertical-align: bottom;
display: inline-block; display: inline-block;

View File

@@ -1,5 +1,7 @@
<template lang="pug" functional> <template lang="pug" functional>
span.message-count(:class="{'top-count': props.top === true}") {{props.count}} span.message-count(
:class="{'top-count': props.top === true, 'top-count-gray': props.gray === true}"
) {{props.count}}
</template> </template>
<style lang="scss"> <style lang="scss">
@@ -24,4 +26,8 @@ span.message-count(:class="{'top-count': props.top === true}") {{props.count}}
padding: 0.2em; padding: 0.2em;
background-color: $red-50; background-color: $red-50;
} }
.message-count.top-count-gray {
background-color: $gray-200;
}
</style> </style>

View File

@@ -0,0 +1,162 @@
<template lang="pug">
.notification.dropdown-item.dropdown-separated.d-flex.justify-content-between(
@click="clicked"
)
.notification-icon.d-flex.justify-content-center.align-items-center(
v-if="hasIcon",
:class="{'is-not-bailey': isNotBailey}",
)
slot(name="icon")
.notification-content
slot(name="content")
.notification-remove(@click.stop="canRemove ? remove() : null",)
.svg-icon(
v-if="canRemove",
v-html="icons.close",
)
</template>
<style lang="scss"> // Not scoped because the classes could be used in i18n strings
@import '~client/assets/scss/colors.scss';
.notification-small {
font-size: 12px;
line-height: 1.33;
color: $gray-200;
}
.notification-ellipses {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.notification-bold {
font-weight: bold;
}
.notification-bold-blue {
font-weight: bold;
color: $blue-10;
}
.notification-bold-purple {
font-weight: bold;
color: $purple-300;
}
.notification-yellow {
color: #bf7d1a;
}
.notification-green {
color: #1CA372;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.notification {
width: 378px;
max-width: 100%;
padding: 9px 20px 10px 24px;
overflow: hidden;
&:active, &:hover {
color: inherit;
}
}
.notification-icon {
margin-right: 16px;
&.is-not-bailey {
width: 31px;
height: 32px;
}
}
.notifications-buttons {
margin-top: 12px;
.btn {
margin-right: 8px;
}
}
.notification-content {
// total distance from notification top and bottom edges are 15 and 16 pixels
margin-top: 6px;
margin-bottom: 6px;
flex-grow: 1;
white-space: normal;
font-size: 14px;
line-height: 1.43;
color: $gray-50;
max-width: calc(100% - 26px); // to make space for the close icon
}
.notification-remove {
// total distance from the notification top edge is 20 pixels
margin-top: 7px;
width: 18px;
height: 18px;
margin-left: 12px;
padding: 4px;
.svg-icon {
width: 10px;
height: 10px;
}
}
</style>
<script>
import closeIcon from 'assets/svg/close.svg';
import { mapActions, mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove', 'hasIcon', 'readAfterClick'],
data () {
return {
icons: Object.freeze({
close: closeIcon,
}),
};
},
computed: {
...mapState({user: 'user.data'}),
isNotBailey () {
return this.notification.type !== 'NEW_STUFF';
},
},
methods: {
...mapActions({
readNotification: 'notifications:readNotification',
}),
clicked () {
if (this.readAfterClick === true) {
this.readNotification({notificationId: this.notification.id});
}
this.$emit('click');
},
remove () {
if (this.notification.type === 'NEW_CHAT_MESSAGE') {
const groupId = this.notification.data.group.id;
this.$store.dispatch('chat:markChatSeen', {groupId});
if (this.user.newMessages[groupId]) {
this.$delete(this.user.newMessages, groupId);
}
} else {
this.readNotification({notificationId: this.notification.id});
}
},
},
};
</script>

View File

@@ -0,0 +1,35 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="true",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content", v-html="$t('cardReceived', {card: cardString})")
div(slot="icon", :class="cardClass")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
cardString () {
return this.$t(`${this.notification.data.card}Card`);
},
cardClass () {
return `notif_inventory_special_${this.notification.data.card}`;
},
},
methods: {
action () {
this.$router.push({name: 'items'});
},
},
};
</script>

View File

@@ -0,0 +1,3 @@
<template lang="pug" functional>
div {{ props.notification }}
</template>

View File

@@ -0,0 +1,72 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
@click="action",
)
div(slot="content")
div(v-html="notification.data.message")
.notifications-buttons
.btn.btn-small.btn-success(@click.stop="approve()") {{ $t('approve') }}
.btn.btn-small.btn-warning(@click.stop="needsWork()") {{ $t('needsWork') }}
</template>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
...mapState({user: 'user.data'}),
// Check that the notification has all the necessary data (old ones are missing some fields)
notificationHasData () {
return Boolean(this.notification.data.groupTaskId && this.notification.data.userId);
},
},
methods: {
action () {
const groupId = this.notification.data.group.id;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
async approve () {
// Redirect users to the group tasks page if the notification doesn't have data
if (!this.notificationHasData) {
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: {
groupId: this.notification.data.groupId,
}});
return;
}
if (!confirm(this.$t('confirmApproval'))) return;
this.$store.dispatch('tasks:approve', {
taskId: this.notification.data.groupTaskId,
userId: this.notification.data.userId,
});
},
async needsWork () {
// Redirect users to the group tasks page if the notification doesn't have data
if (!this.notificationHasData) {
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: {
groupId: this.notification.data.groupId,
}});
return;
}
if (!confirm(this.$t('confirmNeedsWork'))) return;
this.$store.dispatch('tasks:needsWork', {
taskId: this.notification.data.groupTaskId,
userId: this.notification.data.userId,
});
},
},
};
</script>

View File

@@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
.notification-green(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.groupId;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>

View File

@@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
.notification-yellow(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.group.id;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>

View File

@@ -0,0 +1,64 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
@click="action",
)
div(slot="content")
div(v-html="textString")
.notifications-buttons
.btn.btn-small.btn-success(@click.stop="accept()") {{ $t('accept') }}
.btn.btn-small.btn-danger(@click.stop="reject()") {{ $t('reject') }}
</template>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
...mapState({user: 'user.data'}),
isPublicGuild () {
if (this.notification.data.publicGuild === true) return true;
return false;
},
textString () {
const guild = this.notification.data.name;
if (this.isPublicGuild) {
return this.$t('invitedToPublicGuild', {guild});
} else {
return this.$t('invitedToPrivateGuild', {guild});
}
},
},
methods: {
action () {
if (!this.isPublicGuild) return;
const groupId = this.notification.data.id;
this.$router.push({ name: 'guild', params: { groupId } });
},
async accept () {
const group = this.notification.data;
if (group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return;
}
await this.$store.dispatch('guilds:join', {groupId: group.id, type: 'guild'});
this.$router.push({ name: 'guild', params: { groupId: group.id } });
},
reject () {
this.$store.dispatch('guilds:rejectInvite', {groupId: this.notification.data.id, type: 'guild'});
},
},
};
</script>

View File

@@ -0,0 +1,46 @@
<template lang="pug">
// Read automatically from the group page mounted hook
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="false",
@click="action"
)
div(slot="content", v-html="string")
</template>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
...mapState({user: 'user.data'}),
groupId () {
return this.notification.data.group.id;
},
isParty () {
return this.groupId === this.user.party._id;
},
string () {
const stringKey = this.isParty ? 'newMsgParty' : 'newMsgGuild';
return this.$t(stringKey, {name: this.notification.data.group.name});
},
},
methods: {
action () {
if (this.isParty) {
this.$router.push({ name: 'party' });
return;
}
this.$router.push({ name: 'guild', params: { groupId: this.groupId }});
},
},
};
</script>

View File

@@ -0,0 +1,28 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content")
span(v-html="$t('userSentMessage', {user: notification.data.sender.name})")
.notification-small.notification-ellipses {{ notification.data.excerpt }}
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
this.$root.$emit('bv::show::modal', 'inbox-modal');
},
},
};
</script>

View File

@@ -0,0 +1,33 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="true",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content", v-html="$t('newSubscriberItem')")
div(slot="icon", :class="mysteryClass")
</template>
<script>
import BaseNotification from './base';
import moment from 'moment';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
mysteryClass () {
return `notif_inventory_present_${moment().format('MM')}`;
},
},
methods: {
action () {
this.$router.push({name: 'items'});
},
},
};
</script>

View File

@@ -0,0 +1,29 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="true",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content")
.notification-bold-purple {{ $t('newBaileyUpdate') }}
div {{ notification.data.title }}
.npc_bailey(slot="icon")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
this.$root.$emit('bv::show::modal', 'new-stuff');
},
},
};
</script>

View File

@@ -0,0 +1,42 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
)
div(slot="content")
div(v-html="$t('invitedToParty', {party: notification.data.name})")
.notifications-buttons
.btn.btn-small.btn-success(@click.stop="accept()") {{ $t('accept') }}
.btn.btn-small.btn-danger(@click.stop="reject()") {{ $t('reject') }}
</template>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
async accept () {
const group = this.notification.data;
if (group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return;
}
await this.$store.dispatch('guilds:join', {groupId: group.id, type: 'party'});
this.$router.push('/party');
},
reject () {
this.$store.dispatch('guilds:rejectInvite', {groupId: this.notification.data.id, type: 'party'});
},
},
};
</script>

View File

@@ -0,0 +1,63 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
@click="action",
)
div(slot="content")
.message(v-html="$t('invitedToQuest', {quest: questName})")
quest-info(:quest="questData", :small-version="true")
.notifications-buttons
.btn.btn-small.btn-success(@click.stop="questAccept()") {{ $t('accept') }}
.btn.btn-small.btn-danger(@click.stop="questReject()") {{ $t('reject') }}
</template>
<style lang="scss">
.message {
margin-bottom: 8px;
}
</style>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
import quests from 'common/script/content/quests';
import questInfo from 'client/components/shops/quests/questInfo';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
questInfo,
},
computed: {
...mapState({user: 'user.data'}),
questData () {
return quests.quests[this.notification.data.quest];
},
questName () {
return this.questData.text();
},
},
methods: {
action () {
this.$router.push({ name: 'party' });
},
async questAccept () {
let quest = await this.$store.dispatch('quests:sendAction', {
groupId: this.notification.data.partyId,
action: 'quests/accept',
});
this.user.party.quest = quest;
},
async questReject () {
let quest = await this.$store.dispatch('quests:sendAction', {
groupId: this.notification.data.partyId,
action: 'quests/reject',
});
this.user.party.quest = quest;
},
},
};
</script>

View File

@@ -0,0 +1,45 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="true",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content", v-html="$t('unallocatedStatsPoints', {points: notification.data.points})")
.svg-icon(slot="icon", v-html="icons.sparkles")
</template>
<style lang="scss" scoped>
.svg-icon {
width: 23px;
height: 28px;
}
</style>
<script>
import BaseNotification from './base';
import sparklesIcon from 'assets/svg/sparkles.svg';
export default {
props: ['notification', 'canRemove'],
data () {
return {
icons: Object.freeze({
sparkles: sparklesIcon,
}),
};
},
components: {
BaseNotification,
},
methods: {
action () {
this.$root.$emit('habitica:show-profile', {
user: this.$store.state.user.data,
startingPage: 'stats',
});
},
},
};
</script>

View File

@@ -1,289 +1,254 @@
<template lang="pug"> <template lang="pug">
menu-dropdown.item-notifications(:right="true") menu-dropdown.item-notifications(:right="true", @toggled="handleOpenStatusChange", :openStatus="openStatus")
div(slot="dropdown-toggle") div(slot="dropdown-toggle")
div(v-b-tooltip.hover.bottom="$t('notifications')") div(v-b-tooltip.hover.bottom="$t('notifications')")
message-count(v-if='notificationsCount > 0', :count="notificationsCount", :top="true") message-count(
.svg-icon.notifications(v-html="icons.notifications") v-if='notificationsCount > 0',
:count="notificationsCount",
:top="true",
:gray="!hasUnseenNotifications",
)
.top-menu-icon.svg-icon.notifications(v-html="icons.notifications")
div(slot="dropdown-content") div(slot="dropdown-content")
h4.dropdown-item.dropdown-separated(v-if='!hasNoNotifications()') {{ $t('notifications') }} .dropdown-item.dropdown-separated.d-flex.justify-content-between.dropdown-inactive.align-items-center(
h4.dropdown-item.toolbar-notifs-no-messages(v-if='hasNoNotifications()') {{ $t('noNotifications') }} @click.stop=""
a.dropdown-item(v-if='user.party.quest && user.party.quest.RSVPNeeded') )
div {{ $t('invitedTo', {name: quests.quests[user.party.quest.key].text()}) }} h4.dropdown-title(v-once) {{ $t('notifications') }}
div a.small-link.standard-link(@click="dismissAll", :disabled="notificationsCount === 0") {{ $t('dismissAll') }}
button.btn.btn-primary(@click.stop='questAccept(user.party._id)') Accept component(
button.btn.btn-primary(@click.stop='questReject(user.party._id)') Reject :is="notification.type",
a.dropdown-item(v-if='user.purchased.plan.mysteryItems.length', @click='go("/inventory/items")') :key="notification.id",
span.glyphicon.glyphicon-gift v-for="notification in notifications",
span {{ $t('newSubscriberItem') }} :notification="notification",
a.dropdown-item(v-for='(party, index) in user.invitations.parties', :key='party.id') :can-remove="!isActionable(notification)",
div )
span.glyphicon.glyphicon-user .dropdown-item.dropdown-separated.d-flex.justify-content-center.dropdown-inactive.no-notifications.flex-column(
span {{ $t('invitedTo', {name: party.name}) }} v-if="notificationsCount === 0"
div )
button.btn.btn-primary(@click.stop='accept(party, index, "party")') Accept .svg-icon(v-html="icons.success")
button.btn.btn-primary(@click.stop='reject(party, index, "party")') Reject h2 You're all caught up!
a.dropdown-item(v-if='user.flags.cardReceived', @click='go("/inventory/items")') p The notification fairies give you a raucous round of applause! Well done!
span.glyphicon.glyphicon-envelope
span {{ $t('cardReceived') }}
a.dropdown-item(@click.stop='clearCards()')
a.dropdown-item(v-for='(guild, index) in user.invitations.guilds', :key='guild.id')
div
span.glyphicon.glyphicon-user
span {{ $t('invitedTo', {name: guild.name}) }}
div
button.btn.btn-primary(@click.stop='accept(guild, index, "guild")') Accept
button.btn.btn-primary(@click.stop='reject(guild, index, "guild")') Reject
a.dropdown-item(v-if='user.flags.classSelected && !user.preferences.disableClasses && user.stats.points',
@click='showProfile()')
span.glyphicon.glyphicon-plus-sign
span {{ $t('haveUnallocated', {points: user.stats.points}) }}
a.dropdown-item(v-for='message in userNewMessages', :key='message.key')
span(@click='navigateToGroup(message.key)')
span.glyphicon.glyphicon-comment
span {{message.name}}
span.clear-button(@click.stop='clearMessages(message.key)') Clear
a.dropdown-item(v-for='notification in groupNotifications', :key='notification.id')
span(:class="groupApprovalNotificationIcon(notification)")
span {{notification.data.message}}
span.clear-button(@click.stop='viewGroupApprovalNotification(notification)') Clear
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.clear-button { @import '~client/assets/scss/colors.scss';
margin-left: .5em;
.dropdown-item {
padding: 16px 24px;
width: 378px;
}
.dropdown-title {
margin-bottom: 0px;
margin-right: 8px;
line-height: 1.5;
}
.no-notifications {
h2, p {
text-align: center;
color: $gray-200 !important;
}
h2 {
margin-top: 24px;
}
p {
white-space: normal;
margin-bottom: 43px;
margin-left: 24px;
margin-right: 24px;
}
.svg-icon {
margin: 0 auto;
width: 256px;
height: 104px;
}
} }
</style> </style>
<script> <script>
import axios from 'axios'; import { mapState, mapActions } from 'client/libs/store';
import isEmpty from 'lodash/isEmpty';
import map from 'lodash/map';
import { mapState } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import quests from 'common/script/content/quests'; import quests from 'common/script/content/quests';
import notificationsIcon from 'assets/svg/notifications.svg'; import notificationsIcon from 'assets/svg/notifications.svg';
import MenuDropdown from '../ui/customMenuDropdown'; import MenuDropdown from '../ui/customMenuDropdown';
import MessageCount from './messageCount'; import MessageCount from './messageCount';
import successImage from 'assets/svg/success.svg';
// Notifications
import NEW_STUFF from './notifications/newStuff';
import GROUP_TASK_NEEDS_WORK from './notifications/groupTaskNeedsWork';
import GUILD_INVITATION from './notifications/guildInvitation';
import PARTY_INVITATION from './notifications/partyInvitation';
import CHALLENGE_INVITATION from './notifications/challengeInvitation';
import QUEST_INVITATION from './notifications/questInvitation';
import GROUP_TASK_APPROVAL from './notifications/groupTaskApproval';
import GROUP_TASK_APPROVED from './notifications/groupTaskApproved';
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
import CARD_RECEIVED from './notifications/cardReceived';
import NEW_INBOX_MESSAGE from './notifications/newInboxMessage';
import NEW_CHAT_MESSAGE from './notifications/newChatMessage';
export default { export default {
components: { components: {
MenuDropdown, MenuDropdown,
MessageCount, MessageCount,
}, // One component for each type
directives: { NEW_STUFF, GROUP_TASK_NEEDS_WORK,
// bTooltip, GUILD_INVITATION, PARTY_INVITATION, CHALLENGE_INVITATION,
QUEST_INVITATION, GROUP_TASK_APPROVAL, GROUP_TASK_APPROVED,
UNALLOCATED_STATS_POINTS, NEW_MYSTERY_ITEMS, CARD_RECEIVED,
NEW_INBOX_MESSAGE, NEW_CHAT_MESSAGE,
}, },
data () { data () {
return { return {
icons: Object.freeze({ icons: Object.freeze({
notifications: notificationsIcon, notifications: notificationsIcon,
success: successImage,
}), }),
quests, quests,
openStatus: undefined,
actionableNotifications: [
'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION',
'QUEST_INVITATION', 'GROUP_TASK_NEEDS_WORK',
],
// A list of notifications handled by this component,
// listed in the order they should appear in the notifications panel.
// NOTE: Those not listed here won't be shown in the notification panel!
handledNotifications: [
'NEW_STUFF', 'GROUP_TASK_NEEDS_WORK',
'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION',
'QUEST_INVITATION', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED',
'NEW_MYSTERY_ITEMS', 'CARD_RECEIVED',
'NEW_INBOX_MESSAGE', 'NEW_CHAT_MESSAGE', 'UNALLOCATED_STATS_POINTS',
],
}; };
}, },
computed: { computed: {
...mapState({user: 'user.data'}), ...mapState({user: 'user.data'}),
party () { notificationsOrder () {
return {name: ''}; // Returns a map of NOTIFICATION_TYPE -> POSITION
// return this.user.party; const orderMap = {};
this.handledNotifications.forEach((type, index) => {
orderMap[type] = index;
});
return orderMap;
}, },
userNewMessages () { notifications () {
// @TODO: For some reason data becomes corrupted. We should fix this on the server // Convert the notifications not stored in user.notifications
let userNewMessages = []; const notifications = [];
for (let key in this.user.newMessages) {
let message = this.user.newMessages[key]; // Parties invitations
if (message && message.name && message.value) { notifications.push(...this.user.invitations.parties.map(partyInvitation => {
message.key = key; return {
userNewMessages.push(message); type: 'PARTY_INVITATION',
data: partyInvitation,
// Create a custom id for notifications outside user.notifications (must be unique)
id: `custom-party-invitation-${partyInvitation.id}`,
};
}));
// Guilds invitations
notifications.push(...this.user.invitations.guilds.map(guildInvitation => {
return {
type: 'GUILD_INVITATION',
data: guildInvitation,
// Create a custom id for notifications outside user.notifications (must be unique)
id: `custom-guild-invitation-${guildInvitation.id}`,
};
}));
// Quest invitation
if (this.user.party.quest.RSVPNeeded === true) {
notifications.push({
type: 'QUEST_INVITATION',
data: {
quest: this.user.party.quest.key,
partyId: this.user.party._id,
},
// Create a custom id for notifications outside user.notifications (must be unique)
id: `custom-quest-invitation-${this.user.party._id}`,
});
} }
const orderMap = this.notificationsOrder;
// Push the notifications stored in user.notifications
// skipping those not defined in the handledNotifications object
notifications.push(...this.user.notifications.filter(notification => {
if (notification.type === 'UNALLOCATED_STATS_POINTS') {
if (!this.user.flags.classSelected || this.user.preferences.disableClasses) return false;
} }
return userNewMessages;
}, return orderMap[notification.type] !== undefined;
groupNotifications () { }));
return this.$store.state.groupNotifications;
// Sort notifications
notifications.sort((a, b) => { // a and b are notifications
const aOrder = orderMap[a.type];
const bOrder = orderMap[b.type];
if (aOrder === bOrder) return 0; // Same position
if (aOrder > bOrder) return 1; // b is higher
if (aOrder < bOrder) return -1; // a is higher
});
return notifications;
}, },
// The total number of notification, shown inside the dropdown
notificationsCount () { notificationsCount () {
let count = 0; return this.notifications.length;
},
if (this.user.invitations.parties) { hasUnseenNotifications () {
count += this.user.invitations.parties.length; return this.notifications.some((notification) => {
} return notification.seen === false ? true : false;
});
if (this.user.purchased.plan && this.user.purchased.plan.mysteryItems.length) {
count++;
}
if (this.user.invitations.guilds) {
count += this.user.invitations.guilds.length;
}
if (this.user.flags.classSelected && !this.user.preferences.disableClasses && this.user.stats.points) {
count += this.user.stats.points > 0 ? 1 : 0;
}
if (this.userNewMessages) {
count += Object.keys(this.userNewMessages).length;
}
count += this.groupNotifications.length;
return count;
}, },
}, },
methods: { methods: {
// @TODO: I hate this function, we can do better with a hashmap ...mapActions({
selectNotificationValue (mysteryValue, invitationValue, cardValue, readNotifications: 'notifications:readNotifications',
unallocatedValue, messageValue, noneValue, groupApprovalRequested, groupApproved) { seeNotifications: 'notifications:seeNotifications',
let user = this.user; }),
handleOpenStatusChange (openStatus) {
this.openStatus = openStatus === true ? 1 : 0;
if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) { // Mark notifications as seen when the menu is opened
return mysteryValue; if (openStatus) this.markAllAsSeen();
} else if (user.invitations.parties && user.invitations.parties.length > 0 || user.invitations.guilds && user.invitations.guilds.length > 0) {
return invitationValue;
} else if (user.flags.cardReceived) {
return cardValue;
} else if (user.flags.classSelected && !(user.preferences && user.preferences.disableClasses) && user.stats.points) {
return unallocatedValue;
} else if (!isEmpty(user.newMessages)) {
return messageValue;
} else if (!isEmpty(this.groupNotifications)) {
let groupNotificationTypes = map(this.groupNotifications, 'type');
if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVAL') !== -1) {
return groupApprovalRequested;
} else if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVED') !== -1) {
return groupApproved;
}
return noneValue;
} else {
return noneValue;
}
}, },
hasQuestProgress () { markAllAsSeen () {
let user = this.user; const idsToSee = this.notifications.map(notification => {
if (user.party.quest) { // We check explicitly for notification.id not starting with `custom-` because some
let userQuest = quests[user.party.quest.key]; // notification don't follow the standard
// (all those not stored in user.notifications)
if (notification.seen === false && notification.id && notification.id.indexOf('custom-') !== 0) {
return notification.id;
}
}).filter(id => Boolean(id));
if (!userQuest) { if (idsToSee.length > 0) this.seeNotifications({notificationIds: idsToSee});
return false;
}
if (userQuest.boss && user.party.quest.progress.up > 0) {
return true;
}
if (userQuest.collect && user.party.quest.progress.collectedItems > 0) {
return true;
}
}
return false;
}, },
getQuestInfo () { dismissAll () {
let user = this.user; const idsToRead = this.notifications.map(notification => {
let questInfo = {}; // We check explicitly for notification.id not starting with `custom-` because some
if (user.party.quest) { // notification don't follow the standard
let userQuest = quests[user.party.quest.key]; // (all those not stored in user.notifications)
if (!this.isActionable(notification) && notification.id.indexOf('custom-') !== 0) {
return notification.id;
}
}).filter(id => Boolean(id));
this.openStatus = 0;
questInfo.title = userQuest.text(); if (idsToRead.length > 0) this.readNotifications({notificationIds: idsToRead});
if (userQuest.boss) {
questInfo.body = this.$t('questTaskDamage', { damage: user.party.quest.progress.up.toFixed(1) });
} else if (userQuest.collect) {
questInfo.body = this.$t('questTaskCollection', { items: user.party.quest.progress.collectedItems });
}
}
return questInfo;
}, },
clearMessages (key) { isActionable (notification) {
this.$store.dispatch('chat:markChatSeen', {groupId: key}); return this.actionableNotifications.indexOf(notification.type) !== -1;
this.$delete(this.user.newMessages, key);
},
clearCards () {
this.$store.dispatch('chat:clearCards');
},
iconClasses () {
return this.selectNotificationValue(
'glyphicon-gift',
'glyphicon-user',
'glyphicon-envelope',
'glyphicon-plus-sign',
'glyphicon-comment',
'glyphicon-comment inactive',
'glyphicon-question-sign',
'glyphicon-ok-sign'
);
},
hasNoNotifications () {
return this.selectNotificationValue(false, false, false, false, false, true, false, false);
},
viewGroupApprovalNotification (notification) {
this.$store.state.groupNotifications = this.groupNotifications.filter(groupNotif => {
return groupNotif.id !== notification.id;
});
axios.post('/api/v3/notifications/read', {
notificationIds: [notification.id],
});
},
groupApprovalNotificationIcon (notification) {
if (notification.type === 'GROUP_TASK_APPROVAL') {
return 'glyphicon glyphicon-question-sign';
} else if (notification.type === 'GROUP_TASK_APPROVED') {
return 'glyphicon glyphicon-ok-sign';
}
},
go (path) {
this.$router.push(path);
},
navigateToGroup (key) {
if (key === this.party._id || key === this.user.party._id) {
this.go('/party');
return;
}
this.$router.push({ name: 'guild', params: { groupId: key }});
},
async reject (group) {
await this.$store.dispatch('guilds:rejectInvite', {groupId: group.id});
// @TODO: User.sync();
},
async accept (group, index, type) {
if (group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return;
}
if (type === 'party') {
// @TODO: pretty sure mutability is wrong. Need to check React docs
// @TODO mutation to store data should only happen through actions
this.user.invitations.parties.splice(index, 1);
Analytics.updateUser({partyID: group.id});
} else {
this.user.invitations.guilds.splice(index, 1);
}
if (type === 'party') {
this.user.party._id = group.id;
this.$router.push('/party');
} else {
this.user.guilds.push(group.id);
this.$router.push(`/groups/guild/${group.id}`);
}
// @TODO: check for party , type: 'myGuilds'
await this.$store.dispatch('guilds:join', {guildId: group.id});
},
async questAccept (partyId) {
let quest = await this.$store.dispatch('quests:sendAction', {groupId: partyId, action: 'quests/accept'});
this.user.party.quest = quest;
},
async questReject (partyId) {
let quest = await this.$store.dispatch('quests:sendAction', {groupId: partyId, action: 'quests/reject'});
this.user.party.quest = quest;
},
showProfile () {
this.$root.$emit('habitica:show-profile', {
user: this.user,
startingPage: 'stats',
});
}, },
}, },
}; };

View File

@@ -3,7 +3,7 @@ menu-dropdown.item-user(:right="true")
div(slot="dropdown-toggle") div(slot="dropdown-toggle")
div(v-b-tooltip.hover.bottom="$t('user')") div(v-b-tooltip.hover.bottom="$t('user')")
message-count(v-if='user.inbox.newMessages > 0', :count="user.inbox.newMessages", :top="true") message-count(v-if='user.inbox.newMessages > 0', :count="user.inbox.newMessages", :top="true")
.svg-icon.user(v-html="icons.user") .top-menu-icon.svg-icon.user(v-html="icons.user")
.user-dropdown(slot="dropdown-content") .user-dropdown(slot="dropdown-content")
a.dropdown-item.edit-avatar.dropdown-separated(@click='showAvatar()') a.dropdown-item.edit-avatar.dropdown-separated(@click='showAvatar()')
h3 {{ user.profile.name }} h3 {{ user.profile.name }}

View File

@@ -156,6 +156,19 @@ export default {
let lastShownNotifications = []; let lastShownNotifications = [];
let alreadyReadNotification = []; let alreadyReadNotification = [];
// A list of notifications handled by this component,
// NOTE: Those not listed here won't be handled at all!
const handledNotifications = {};
[
'GUILD_PROMPT', 'DROPS_ENABLED', 'REBIRTH_ENABLED', 'WON_CHALLENGE', 'STREAK_ACHIEVEMENT',
'ULTIMATE_GEAR_ACHIEVEMENT', 'REBIRTH_ACHIEVEMENT', 'GUILD_JOINED_ACHIEVEMENT',
'CHALLENGE_JOINED_ACHIEVEMENT', 'INVITED_FRIEND_ACHIEVEMENT', 'NEW_CONTRIBUTOR_LEVEL',
'CRON', 'SCORED_TASK', 'LOGIN_INCENTIVE',
].forEach(type => {
handledNotifications[type] = true;
});
return { return {
yesterDailies: [], yesterDailies: [],
levelBeforeYesterdailies: 0, levelBeforeYesterdailies: 0,
@@ -165,54 +178,31 @@ export default {
alreadyReadNotification, alreadyReadNotification,
isRunningYesterdailies: false, isRunningYesterdailies: false,
nextCron: null, nextCron: null,
handledNotifications,
}; };
}, },
computed: { computed: {
...mapState({user: 'user.data'}),
// https://stackoverflow.com/questions/42133894/vue-js-how-to-properly-watch-for-nested-properties/42134176#42134176 // https://stackoverflow.com/questions/42133894/vue-js-how-to-properly-watch-for-nested-properties/42134176#42134176
baileyShouldShow () { ...mapState({
return this.user.flags.newStuff; user: 'user.data',
}, userHp: 'user.data.stats.hp',
userHp () { userExp: 'user.data.stats.exp',
return this.user.stats.hp; userGp: 'user.data.stats.gp',
}, userMp: 'user.data.stats.mp',
userExp () { userLvl: 'user.data.stats.lvl',
return this.user.stats.exp; userNotifications: 'user.data.notifications',
}, userAchievements: 'user.data.achievements', // @TODO: does this watch deeply?
userGp () { armoireEmpty: 'user.data.flags.armoireEmpty',
return this.user.stats.gp; questCompleted: 'user.data.party.quest.completed',
}, }),
userMp () {
return this.user.stats.mp;
},
userLvl () {
return this.user.stats.lvl;
},
userClassSelect () { userClassSelect () {
return !this.user.flags.classSelected && this.user.stats.lvl >= 10; return !this.user.flags.classSelected && this.user.stats.lvl >= 10;
}, },
userNotifications () {
return this.user.notifications;
},
userAchievements () {
// @TODO: does this watch deeply?
return this.user.achievements;
},
armoireEmpty () {
return this.user.flags.armoireEmpty;
},
questCompleted () {
return this.user.party.quest.completed;
},
invitedToQuest () { invitedToQuest () {
return this.user.party.quest.RSVPNeeded && !this.user.party.quest.completed; return this.user.party.quest.RSVPNeeded && !this.user.party.quest.completed;
}, },
}, },
watch: { watch: {
baileyShouldShow () {
if (this.user.needsCron) return;
this.$root.$emit('bv::show::modal', 'new-stuff');
},
userHp (after, before) { userHp (after, before) {
if (after <= 0) { if (after <= 0) {
this.playSound('Death'); this.playSound('Death');
@@ -419,9 +409,6 @@ export default {
this.scheduleNextCron(); this.scheduleNextCron();
this.handleUserNotifications(this.user.notifications); this.handleUserNotifications(this.user.notifications);
}, },
transferGroupNotification (notification) {
this.$store.state.groupNotifications.push(notification);
},
async handleUserNotifications (after) { async handleUserNotifications (after) {
if (this.$store.state.isRunningYesterdailies) return; if (this.$store.state.isRunningYesterdailies) return;
@@ -434,23 +421,21 @@ export default {
let notificationsToRead = []; let notificationsToRead = [];
let scoreTaskNotification = []; let scoreTaskNotification = [];
this.$store.state.groupNotifications = []; // Flush group notifictions
after.forEach((notification) => { after.forEach((notification) => {
// This notification type isn't implemented here
if (!this.handledNotifications[notification.type]) return;
if (this.lastShownNotifications.indexOf(notification.id) !== -1) { if (this.lastShownNotifications.indexOf(notification.id) !== -1) {
return; return;
} }
// Some notifications are not marked read here, so we need to fix this system
// to handle notifications differently
if (['GROUP_TASK_APPROVED', 'GROUP_TASK_APPROVAL'].indexOf(notification.type) === -1) {
this.lastShownNotifications.push(notification.id); this.lastShownNotifications.push(notification.id);
if (this.lastShownNotifications.length > 10) { if (this.lastShownNotifications.length > 10) {
this.lastShownNotifications.splice(0, 9); this.lastShownNotifications.splice(0, 9);
} }
}
let markAsRead = true; let markAsRead = true;
// @TODO: Use factory function instead // @TODO: Use factory function instead
switch (notification.type) { switch (notification.type) {
case 'GUILD_PROMPT': case 'GUILD_PROMPT':
@@ -507,14 +492,6 @@ export default {
if (notification.data.mp) this.mp(notification.data.mp); if (notification.data.mp) this.mp(notification.data.mp);
} }
break; break;
case 'GROUP_TASK_APPROVAL':
this.transferGroupNotification(notification);
markAsRead = false;
break;
case 'GROUP_TASK_APPROVED':
this.transferGroupNotification(notification);
markAsRead = false;
break;
case 'SCORED_TASK': case 'SCORED_TASK':
// Search if it is a read notification // Search if it is a read notification
for (let i = 0; i < this.alreadyReadNotification.length; i++) { for (let i = 0; i < this.alreadyReadNotification.length; i++) {
@@ -538,16 +515,6 @@ export default {
this.$root.$emit('bv::show::modal', 'login-incentives'); this.$root.$emit('bv::show::modal', 'login-incentives');
} }
break; break;
default:
if (notification.data.headerText && notification.data.bodyText) {
// @TODO:
// let modalScope = this.$new();
// modalScope.data = notification.data;
// this.openModal('generic', {scope: modalScope});
} else {
markAsRead = false; // If the notification is not implemented, skip it
}
break;
} }
if (markAsRead) notificationsToRead.push(notification.id); if (markAsRead) notificationsToRead.push(notification.id);
@@ -561,6 +528,7 @@ export default {
}); });
} }
// @TODO this code is never run because userReadNotifsPromise is never true
if (userReadNotifsPromise) { if (userReadNotifsPromise) {
userReadNotifsPromise.then(() => { userReadNotifsPromise.then(() => {
// Only run this code for scoring approved tasks // Only run this code for scoring approved tasks
@@ -589,8 +557,6 @@ export default {
}); });
} }
this.user.notifications = []; // reset the notifications
this.checkUserAchievements(); this.checkUserAchievements();
}, },
}, },

View File

@@ -35,7 +35,7 @@
h5 {{ $t('characterBuild') }} h5 {{ $t('characterBuild') }}
h6(v-once) {{ $t('class') + ': ' }} h6(v-once) {{ $t('class') + ': ' }}
// @TODO: what is classText // @TODO: what is classText
span(v-if='classText') {{ classText }}&nbsp; // span(v-if='classText') {{ classText }}&nbsp;
button.btn.btn-danger.btn-xs(@click='changeClassForUser(true)', v-once) {{ $t('changeClass') }} button.btn.btn-danger.btn-xs(@click='changeClassForUser(true)', v-once) {{ $t('changeClass') }}
small.cost &nbsp; 3 {{ $t('gems') }} small.cost &nbsp; 3 {{ $t('gems') }}
// @TODO add icon span.Pet_Currency_Gem1x.inline-gems // @TODO add icon span.Pet_Currency_Gem1x.inline-gems
@@ -291,7 +291,6 @@ export default {
// Guide.goto('intro', 0, true); // Guide.goto('intro', 0, true);
}, },
showBailey () { showBailey () {
this.user.flags.newStuff = true;
this.$root.$emit('bv::show::modal', 'new-stuff'); this.$root.$emit('bv::show::modal', 'new-stuff');
}, },
hasBackupAuthOption (networkKeyToCheck) { hasBackupAuthOption (networkKeyToCheck) {

View File

@@ -1,40 +1,52 @@
<template lang="pug"> <template lang="pug">
div.row .row(:class="{'small-version': smallVersion}")
span.col-4(v-if="quest.collect") {{ $t('collect') + ':' }} template(v-if="quest.collect")
span.col-8(v-if="quest.collect") span.title(:class="smallVersion ? 'col-3' : 'col-4'") {{ $t('collect') + ':' }}
span.col-8
div(v-for="(collect, key) of quest.collect") div(v-for="(collect, key) of quest.collect")
span {{ collect.count }} {{ getCollectText(collect) }} span {{ collect.count }} {{ getCollectText(collect) }}
span.col-4(v-if="quest.boss") {{ $t('bossHP') + ':' }} template(v-if="quest.boss")
span.col-8(v-if="quest.boss") {{ quest.boss.hp }} span.title(:class="smallVersion ? 'col-3' : 'col-4'") {{ $t('bossHP') + ':' }}
span.col-8 {{ quest.boss.hp }}
span.col-4 {{ $t('difficulty') + ':' }} span.title(:class="smallVersion ? 'col-3' : 'col-4'") {{ $t('difficulty') + ':' }}
span.col-8 span.col-8
span.svg-icon.inline.icon-16(v-for="star of stars()", v-html="icons[star]") .svg-icon.inline(
v-for="star of stars()", v-html="icons[star]",
:class="smallVersion ? 'icon-12' : 'icon-16'",
)
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/colors.scss'; .title {
.col-4{
text-align: left; text-align: left;
font-weight: bold; font-weight: bold;
white-space: nowrap; white-space: nowrap;
height: 16px; }
width: 80px;
}
.col-8 { .col-8 {
text-align: left; text-align: left;
} }
.col-8:not(:last-child) { .col-8:not(:last-child) {
margin-bottom: 4px; margin-bottom: 4px;
} }
span.svg-icon.inline.icon-16 { .svg-icon {
margin-right: 4px; margin-right: 4px;
}
.small-version {
font-size: 12px;
line-height: 1.33;
.svg-icon {
margin-top: 1px;
} }
}
</style> </style>
<script> <script>
@@ -43,6 +55,15 @@
import svgStarEmpty from 'assets/svg/difficulty-star-empty.svg'; import svgStarEmpty from 'assets/svg/difficulty-star-empty.svg';
export default { export default {
props: {
quest: {
type: Object,
},
smallVersion: {
type: Boolean,
default: false,
},
},
data () { data () {
return { return {
icons: Object.freeze({ icons: Object.freeze({
@@ -88,10 +109,5 @@
} }
}, },
}, },
props: {
quest: {
type: Object,
},
},
}; };
</script> </script>

View File

@@ -1,5 +1,5 @@
<template lang="pug"> <template lang="pug">
#front #front.static-view
noscript.banner {{ $t('jsDisabledHeadingFull') }} noscript.banner {{ $t('jsDisabledHeadingFull') }}
br br
a(href='http://www.enable-javascript.com/', target='_blank') {{ $t('jsDisabledLink') }} a(href='http://www.enable-javascript.com/', target='_blank') {{ $t('jsDisabledLink') }}
@@ -118,8 +118,12 @@
.seamless_stars_varied_opacity_repeat .seamless_stars_varied_opacity_repeat
</template> </template>
<style lang='scss'>
@import '~client/assets/scss/static.scss';
</style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/static.scss'; @import '~client/assets/scss/colors.scss';
#front { #front {
.form-text a { .form-text a {

View File

@@ -1,82 +1,23 @@
<template lang='pug'> <template lang='pug'>
div .static-view(v-html="html")
.media
.align-self-center.right-margin(:class='baileyClass')
.media-body
h1.align-self-center(v-markdown='$t("newStuff")')
h2 1/30/2018 - HABITICA BIRTHDAY CELEBRATION, LAST CHANCE FOR WINTER WONDERLAND ITEMS, AND CONTINUED RESOLUTION PLOT-LINE
hr
.promo_habit_birthday_2018.center-block
h3 Habitica Birthday Party!
p January 31st is Habitica's Birthday! Thank you so much for being a part of our community - it means a lot.
p Now come join us and the NPCs as we celebrate!
h4 Cake for Everybody!
p(v-markdown='"In honor of the festivities, everyone has been awarded an assortment of yummy cake to feed to your pets! Plus, for the next two days [Alexander the Merchant](/shops/market) is selling cake in his shop, and cake will sometimes drop when you complete your tasks. Cake works just like normal pet food, but if you want to know what type of pet likes each slice, [the wiki has spoilers](http://habitica.wikia.com/wiki/Food)."')
h4 Party Robes
p There are Party Robes available for free in the Rewards column! Don them with pride.
h4 Birthday Bash Achievement
p In honor of Habitica's birthday, everyone has been awarded the Habitica Birthday Bash achievement! This achievement stacks for each Birthday Bash you celebrate with us.
.media
.media-body
h3 Last Chance for Frost Sprite Set
p(v-markdown='"Reminder: this is the final day to [subscribe](/user/settings/subscription) and receive the Frost Sprite Set! Subscribing also lets you buy gems for gold. The longer your subscription, the more gems you get!"')
p Thanks so much for your support! You help keep Habitica running.
.small by Beffymaroo
h3 Last Chance for Starry Night and Holly Hatching Potions
p(v-markdown='"Reminder: this is the final day to [buy Starry Night and Holly Hatching Potions!](/shops/market) If they come back, it won\'t be until next year at the earliest, so don\'t delay!"')
.small by Vampitch, JinjooHat, Lemoness, and SabreCat
h3 Resolution Plot-Line: Broken Buildings
p Lemoness, SabreCat, and Beffymaroo call an important meeting to address the rumors that are flying about this strange outbreak of Habiticans who are suddenly losing all faith in their ability to complete their New Year's Resolutions.
p “Thank you all for coming,” Lemoness says. “I'm afraid that we have some very serious news to share, but we ask that you remain calm.
.promo_starry_potions.left-margin
p While it's natural to feel a little disheartened as the end of January approaches,” Beffymaroo says, “these sudden outbreaks appear to have some strange magical origin. We're still investigating the exact cause, but we do know that the buildings where the affected Habiticans live often seem to sustain some damage immediately before the attack.
p SabreCat clears his throat. For this reason, we strongly encourage everyone to stay away from broken-down structures, and if you feel any strange tremors or hear odd sounds, please report them immediately.
p(v-markdown='"“Stay safe, Habiticans.” Lemoness flashes her best comforting smile. “And remember that if your New Year\'s Resolution goals seem daunting, you can always seek support in the [New Year\'s Resolution Guild](https://habitica.com/groups/guild/6e6a8bd3-9f5f-4351-9188-9f11fcd80a99).”"')
p How mysterious! Hopefully they'll get to the bottom of this soon.
hr
</template> </template>
<style lang='scss' scoped> <style lang='scss'>
@import '~client/assets/scss/static.scss'; @import '~client/assets/scss/static.scss';
.center-block {
margin: 0 auto 1em auto;
}
.left-margin {
margin-left: 1em;
}
.right-margin {
margin-right: 1em;
}
.bottom-margin {
margin-bottom: 1em;
}
.small {
margin-bottom: 1em;
}
</style> </style>
<script> <script>
import markdown from 'client/directives/markdown'; import axios from 'axios';
export default { export default {
data () { data () {
let worldDmg = {
bailey: false,
};
return { return {
baileyClass: { html: '',
'npc_bailey_broken': worldDmg.bailey, // eslint-disable-line
'npc_bailey': !worldDmg.bailey, // eslint-disable-line
},
}; };
}, },
directives: { async mounted () {
markdown, let response = await axios.get('/api/v3/news');
this.html = response.data.html;
}, },
}; };
</script> </script>

View File

@@ -1,5 +1,5 @@
<template lang="pug"> <template lang="pug">
.container-fluid .container-fluid.static-view
.row .row
.col-md-6.offset-3 .col-md-6.offset-3
h1 {{ $t('overview') }} h1 {{ $t('overview') }}
@@ -11,9 +11,11 @@
p(v-markdown="$t('overviewQuestions')") p(v-markdown="$t('overviewQuestions')")
</template> </template>
<style lang='scss' scoped> <style lang='scss'>
@import '~client/assets/scss/static.scss'; @import '~client/assets/scss/static.scss';
</style>
<style lang='scss' scoped>
.container-fluid { .container-fluid {
margin-top: 56px; margin-top: 56px;
} }

View File

@@ -4,15 +4,15 @@ div
.claim-bottom-message.col-12 .claim-bottom-message.col-12
.task-unclaimed.d-flex.justify-content-between(v-if='!approvalRequested && !multipleApprovalsRequested') .task-unclaimed.d-flex.justify-content-between(v-if='!approvalRequested && !multipleApprovalsRequested')
span {{ message }} span {{ message }}
a.text-right(@click='claim()', v-if='!userIsAssigned') Claim a.text-right(@click='claim()', v-if='!userIsAssigned') {{ $t('claim') }}
a.text-right(@click='unassign()', v-if='userIsAssigned') Remove Claim a.text-right(@click='unassign()', v-if='userIsAssigned') {{ $t('removeClaim') }}
.row.task-single-approval(v-if='approvalRequested') .row.task-single-approval(v-if='approvalRequested')
.col-6.text-center .col-6.text-center
a(@click='approve()') Approve Task a(@click='approve()') {{ $t('approveTask') }}
// @TODO: Implement in v2 .col-6.text-center .col-6.text-center
a Needs work a(@click='needsWork()') {{ $t('needsWork') }}
.text-center.task-multi-approval(v-if='multipleApprovalsRequested') .text-center.task-multi-approval(v-if='multipleApprovalsRequested')
a(@click='showRequests()') View Requests a(@click='showRequests()') {{ $t('viewRequests') }}
</template> </template>
<style lang="scss", scoped> <style lang="scss", scoped>
@@ -116,15 +116,21 @@ export default {
approve () { approve () {
if (!confirm(this.$t('confirmApproval'))) return; if (!confirm(this.$t('confirmApproval'))) return;
let userIdToApprove = this.task.group.assignedUsers[0]; let userIdToApprove = this.task.group.assignedUsers[0];
this.$store.dispatch('tasks:unassignTask', { this.$store.dispatch('tasks:approve', {
taskId: this.task._id, taskId: this.task._id,
userId: userIdToApprove, userId: userIdToApprove,
}); });
this.task.group.assignedUsers.splice(0, 1); this.task.group.assignedUsers.splice(0, 1);
this.task.approvals.splice(0, 1); this.task.approvals.splice(0, 1);
}, },
reject () { needsWork () {
if (!confirm(this.$t('confirmNeedsWork'))) return;
let userIdNeedsMoreWork = this.task.group.assignedUsers[0];
this.$store.dispatch('tasks:needsWork', {
taskId: this.task._id,
userId: userIdNeedsMoreWork,
});
this.task.approvals.splice(0, 1);
}, },
showRequests () { showRequests () {
this.$root.$emit('bv::show::modal', 'approval-modal'); this.$root.$emit('bv::show::modal', 'approval-modal');

View File

@@ -1,11 +1,13 @@
<template lang="pug"> <template lang="pug">
b-modal#approval-modal(title="Approve Task", size='md', :hide-footer="true") b-modal#approval-modal(:title="$t('approveTask')", size='md', :hide-footer="true")
.modal-body .modal-body
.row.approval(v-for='(approval, index) in task.approvals') .row.approval(v-for='(approval, index) in task.approvals')
.col-8 .col-8
strong {{approval.userId.profile.name}} strong {{approval.userId.profile.name}}
.col-2 .col-2
button.btn.btn-primary(@click='approve(index)') Approve button.btn.btn-primary(@click='approve(index)') {{ $t('approve') }}
.col-2
button.btn.btn-secondary(@click='needsWork(index)') {{ $t('needsWork') }}
.modal-footer .modal-footer
button.btn.btn-secondary(@click='close()') {{$t('close')}} button.btn.btn-secondary(@click='close()') {{$t('close')}}
</template> </template>
@@ -22,15 +24,24 @@ export default {
props: ['task'], props: ['task'],
methods: { methods: {
approve (index) { approve (index) {
if (!confirm('Are you sure you want to approve this task?')) return; if (!confirm(this.$t('confirmApproval'))) return;
let userIdToApprove = this.task.group.assignedUsers[index]; let userIdToApprove = this.task.group.assignedUsers[index];
this.$store.dispatch('tasks:unassignTask', { this.$store.dispatch('tasks:approve', {
taskId: this.task._id, taskId: this.task._id,
userId: userIdToApprove, userId: userIdToApprove,
}); });
this.task.group.assignedUsers.splice(index, 1); this.task.group.assignedUsers.splice(index, 1);
this.task.approvals.splice(index, 1); this.task.approvals.splice(index, 1);
}, },
needsWork (index) {
if (!confirm(this.$t('confirmNeedsWork'))) return;
let userIdNeedsMoreWork = this.task.group.assignedUsers[index];
this.$store.dispatch('tasks:needsWork', {
taskId: this.task._id,
userId: userIdNeedsMoreWork,
});
this.task.approvals.splice(index, 1);
},
close () { close () {
this.$root.$emit('bv::hide::modal', 'approval-modal'); this.$root.$emit('bv::hide::modal', 'approval-modal');
}, },

View File

@@ -3,7 +3,7 @@ A simplified dropdown component that doesn't rely on buttons as toggles like bo
--> -->
<template lang="pug"> <template lang="pug">
.habitica-menu-dropdown.item-with-icon.dropdown(@click="toggleDropdown()", :class="{open: isDropdownOpen}") .habitica-menu-dropdown.dropdown(@click="toggleDropdown()", :class="{open: isOpen}")
.habitica-menu-dropdown-toggle .habitica-menu-dropdown-toggle
slot(name="dropdown-toggle") slot(name="dropdown-toggle")
.dropdown-menu(:class="{'dropdown-menu-right': right}") .dropdown-menu(:class="{'dropdown-menu-right': right}")
@@ -43,7 +43,7 @@ A simplified dropdown component that doesn't rely on buttons as toggles like bo
&.open { &.open {
.dropdown-menu { .dropdown-menu {
display: block; display: block;
margin-top: 16px; margin-top: 8px;
} }
} }
} }
@@ -51,12 +51,22 @@ A simplified dropdown component that doesn't rely on buttons as toggles like bo
<script> <script>
export default { export default {
props: ['right'], props: {
right: Boolean,
openStatus: Number,
},
data () { data () {
return { return {
isDropdownOpen: false, isDropdownOpen: false,
}; };
}, },
computed: {
isOpen () {
// Open status is a number so we can tell if the value was passed
if (this.openStatus !== undefined) return this.openStatus === 1 ? true : false;
return this.isDropdownOpen;
},
},
mounted () { mounted () {
document.documentElement.addEventListener('click', this._clickOutListener); document.documentElement.addEventListener('click', this._clickOutListener);
}, },
@@ -65,12 +75,13 @@ export default {
}, },
methods: { methods: {
_clickOutListener (e) { _clickOutListener (e) {
if (!this.$el.contains(e.target) && this.isDropdownOpen) { if (!this.$el.contains(e.target) && this.isOpen) {
this.toggleDropdown(); this.toggleDropdown();
} }
}, },
toggleDropdown () { toggleDropdown () {
this.isDropdownOpen = !this.isDropdownOpen; this.isDropdownOpen = !this.isOpen;
this.$emit('toggled', this.isDropdownOpen);
}, },
}, },
}; };

View File

@@ -86,8 +86,3 @@ export async function markChatSeen (store, payload) {
let response = await axios.post(url); let response = await axios.post(url);
return response.data.data; return response.data.data;
} }
// @TODO: should this be here?
// function clearCards () {
// User.user._wrapped && User.set({'flags.cardReceived':false});
// }

View File

@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import findIndex from 'lodash/findIndex'; import findIndex from 'lodash/findIndex';
import * as Analytics from 'client/libs/analytics';
export async function getPublicGuilds (store, payload) { export async function getPublicGuilds (store, payload) {
let params = { let params = {
@@ -49,12 +50,26 @@ export async function getGroup (store, payload) {
export async function join (store, payload) { export async function join (store, payload) {
let response = await axios.post(`/api/v3/groups/${payload.guildId}/join`); const groupId = payload.groupId;
const type = payload.type;
const user = store.state.user.data;
const invitations = user.invitations;
// @TODO: abstract for parties as well let response = await axios.post(`/api/v3/groups/${groupId}/join`);
store.state.user.data.guilds.push(payload.guildId);
if (payload.type === 'myGuilds') { if (type === 'guild') {
const invitationI = invitations.guilds.findIndex(i => i.id === groupId);
if (invitationI !== -1) invitations.guilds.splice(invitationI, 1);
user.guilds.push(groupId);
store.state.myGuilds.push(response.data.data); store.state.myGuilds.push(response.data.data);
} else if (type === 'party') {
const invitationI = invitations.parties.findIndex(i => i.id === groupId);
if (invitationI !== -1) invitations.parties.splice(invitationI, 1);
user.party._id = groupId;
Analytics.updateUser({partyID: groupId});
} }
return response.data.data; return response.data.data;
@@ -111,9 +126,20 @@ export async function update (store, payload) {
} }
export async function rejectInvite (store, payload) { export async function rejectInvite (store, payload) {
let response = await axios.post(`/api/v3/groups/${payload.groupId}/reject-invite`); const groupId = payload.groupId;
const type = payload.type;
const user = store.state.user.data;
const invitations = user.invitations;
// @TODO: refresh or add guild let response = await axios.post(`/api/v3/groups/${groupId}/reject-invite`);
if (type === 'guild') {
const invitationI = invitations.guilds.findIndex(i => i.id === groupId);
if (invitationI !== -1) invitations.guilds.splice(invitationI, 1);
} else if (type === 'party') {
const invitationI = invitations.parties.findIndex(i => i.id === groupId);
if (invitationI !== -1) invitations.parties.splice(invitationI, 1);
}
return response; return response;
} }

View File

@@ -1,13 +1,27 @@
import axios from 'axios'; import axios from 'axios';
export async function readNotification (store, payload) { export async function readNotification (store, payload) {
let url = `api/v3/notifications/${payload.notificationId}/read`; let url = `/api/v3/notifications/${payload.notificationId}/read`;
let response = await axios.post(url); let response = await axios.post(url);
return response.data.data; return response.data.data;
} }
export async function readNotifications (store, payload) { export async function readNotifications (store, payload) {
let url = 'api/v3/notifications/read'; let url = '/api/v3/notifications/read';
let response = await axios.post(url, {
notificationIds: payload.notificationIds,
});
return response.data.data;
}
export async function seeNotification (store, payload) {
let url = `/api/v3/notifications/${payload.notificationId}/see`;
let response = await axios.post(url);
return response.data.data;
}
export async function seeNotifications (store, payload) {
let url = '/api/v3/notifications/see';
let response = await axios.post(url, { let response = await axios.post(url, {
notificationIds: payload.notificationIds, notificationIds: payload.notificationIds,
}); });

View File

@@ -191,6 +191,11 @@ export async function unassignTask (store, payload) {
return response.data.data; return response.data.data;
} }
export async function needsWork (store, payload) {
let response = await axios.post(`/api/v3/tasks/${payload.taskId}/needs-work/${payload.userId}`);
return response.data.data;
}
export async function getGroupApprovals (store, payload) { export async function getGroupApprovals (store, payload) {
let response = await axios.get(`/api/v3/approvals/group/${payload.groupId}`); let response = await axios.get(`/api/v3/approvals/group/${payload.groupId}`);
return response.data.data; return response.data.data;

View File

@@ -120,6 +120,10 @@ export function openMysteryItem () {
return axios.post('/api/v3/user/open-mystery-item'); return axios.post('/api/v3/user/open-mystery-item');
} }
export function newStuffLater () {
return axios.post('/api/v3/news/tell-me-later');
}
export async function rebirth () { export async function rebirth () {
let result = await axios.post('/api/v3/user/rebirth'); let result = await axios.post('/api/v3/user/rebirth');

View File

@@ -128,7 +128,6 @@ export default function () {
modalStack: [], modalStack: [],
equipmentDrawerOpen: true, equipmentDrawerOpen: true,
groupPlans: [], groupPlans: [],
groupNotifications: [],
isRunningYesterdailies: false, isRunningYesterdailies: false,
}, },
}); });

View File

@@ -64,7 +64,7 @@
"groupPlansTitle": "Group Plans", "groupPlansTitle": "Group Plans",
"newGroupTitle": "New Group", "newGroupTitle": "New Group",
"subscriberItem": "Mystery Item", "subscriberItem": "Mystery Item",
"newSubscriberItem": "New Mystery Item", "newSubscriberItem": "You have new <span class=\"notification-bold-blue\">Mystery Items</span>",
"subscriberItemText": "Each month, subscribers will receive a mystery item. This is usually released about one week before the end of the month. See the wiki's 'Mystery Item' page for more information.", "subscriberItemText": "Each month, subscribers will receive a mystery item. This is usually released about one week before the end of the month. See the wiki's 'Mystery Item' page for more information.",
"all": "All", "all": "All",
"none": "None", "none": "None",
@@ -173,8 +173,7 @@
"achievementBewilderText": "Helped defeat the Be-Wilder during the 2016 Spring Fling Event!", "achievementBewilderText": "Helped defeat the Be-Wilder during the 2016 Spring Fling Event!",
"checkOutProgress": "Check out my progress in Habitica!", "checkOutProgress": "Check out my progress in Habitica!",
"cards": "Cards", "cards": "Cards",
"cardReceived": "Received a card!", "cardReceived": "You received a <span class=\"notification-bold-blue\"><%= card %></span>",
"cardReceivedFrom": "<%= cardType %> from <%= userName %>",
"greetingCard": "Greeting Card", "greetingCard": "Greeting Card",
"greetingCardExplanation": "You both receive the Cheery Chum achievement!", "greetingCardExplanation": "You both receive the Cheery Chum achievement!",
"greetingCardNotes": "Send a greeting card to a party member.", "greetingCardNotes": "Send a greeting card to a party member.",
@@ -281,10 +280,11 @@
"spirituality": "Spirituality", "spirituality": "Spirituality",
"time_management": "Time-Management + Accountability", "time_management": "Time-Management + Accountability",
"recovery_support_groups": "Recovery + Support Groups", "recovery_support_groups": "Recovery + Support Groups",
"dismissAll": "Dismiss All",
"messages": "Messages", "messages": "Messages",
"emptyMessagesLine1": "You don't have any messages", "emptyMessagesLine1": "You don't have any messages",
"emptyMessagesLine2": "Send a message to start a conversation!", "emptyMessagesLine2": "Send a message to start a conversation!",
"userSentMessage": "<span class=\"notification-bold\"><%= user %></span> sent you a message",
"letsgo": "Let's Go!", "letsgo": "Let's Go!",
"selected": "Selected", "selected": "Selected",
"howManyToBuy": "How many would you like to buy?", "howManyToBuy": "How many would you like to buy?",

View File

@@ -48,8 +48,9 @@
"userId": "User ID", "userId": "User ID",
"invite": "Invite", "invite": "Invite",
"leave": "Leave", "leave": "Leave",
"invitedTo": "Invited to <%= name %>", "invitedToParty": "You were invited to join the Party <span class=\"notification-bold\"><%= party %></span>",
"invitedToNewParty": "You were invited to join a party! Do you want to leave this party, reject all other party invitations and join <%= partyName %>?", "invitedToPrivateGuild": "You were invited to join the private Guild <span class=\"notification-bold\"><%= guild %></span>",
"invitedToPublicGuild": "You were invited to join the Guild <span class=\"notification-bold-blue\"><%= guild %></span>",
"partyInvitationsText": "You have <%= numberInvites %> party invitations! Choose wisely, because you can only be in one party at a time.", "partyInvitationsText": "You have <%= numberInvites %> party invitations! Choose wisely, because you can only be in one party at a time.",
"joinPartyConfirmationText": "Are you sure you want to join the party \"<%= partyName %>\"? You can only be in one party at a time. If you join, all other party invitations will be rejected.", "joinPartyConfirmationText": "Are you sure you want to join the party \"<%= partyName %>\"? You can only be in one party at a time. If you join, all other party invitations will be rejected.",
"invitationAcceptedHeader": "Your Invitation has been Accepted", "invitationAcceptedHeader": "Your Invitation has been Accepted",
@@ -61,7 +62,8 @@
"partyLoading3": "Your party is gathering. Please wait...", "partyLoading3": "Your party is gathering. Please wait...",
"partyLoading4": "Your party is materializing. Please wait...", "partyLoading4": "Your party is materializing. Please wait...",
"systemMessage": "System Message", "systemMessage": "System Message",
"newMsg": "New message in \"<%= name %>\"", "newMsgGuild": "There are new posts in the Guild <span class=\"notification-bold-blue\"><%= name %></span>",
"newMsgParty": "There are new posts in your Party <span class=\"notification-bold-blue\"><%= name %></span>",
"chat": "Chat", "chat": "Chat",
"sendChat": "Send Chat", "sendChat": "Send Chat",
"toolTipMsg": "Fetch Recent Messages", "toolTipMsg": "Fetch Recent Messages",
@@ -154,8 +156,6 @@
"copyAsTodo": "Copy as To-Do", "copyAsTodo": "Copy as To-Do",
"messageAddedAsToDo": "Message copied as To-Do.", "messageAddedAsToDo": "Message copied as To-Do.",
"messageWroteIn": "<%= user %> wrote in <%= group %>", "messageWroteIn": "<%= user %> wrote in <%= group %>",
"taskFromInbox": "<%= from %> wrote '<%= message %>'",
"taskTextFromInbox": "Message from <%= from %>",
"msgPreviewHeading": "Message Preview", "msgPreviewHeading": "Message Preview",
"leaderOnlyChallenges": "Only group leader can create challenges", "leaderOnlyChallenges": "Only group leader can create challenges",
"sendGift": "Send Gift", "sendGift": "Send Gift",
@@ -246,6 +246,7 @@
"confirmClaim": "Are you sure you want to claim this task?", "confirmClaim": "Are you sure you want to claim this task?",
"confirmUnClaim": "Are you sure you want to unclaim this task?", "confirmUnClaim": "Are you sure you want to unclaim this task?",
"confirmApproval": "Are you sure you want to approve this task?", "confirmApproval": "Are you sure you want to approve this task?",
"confirmNeedsWork": "Are you sure you want to mark this task as needing work?",
"userRequestsApproval": "<%= userName %> requests approval", "userRequestsApproval": "<%= userName %> requests approval",
"userCountRequestsApproval": "<%= userCount %> request approval", "userCountRequestsApproval": "<%= userCount %> request approval",
"youAreRequestingApproval": "You are requesting approval", "youAreRequestingApproval": "You are requesting approval",
@@ -264,10 +265,15 @@
"assignTask": "Assign Task", "assignTask": "Assign Task",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.", "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"claim": "Claim", "claim": "Claim",
"removeClaim": "Remove Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"yourTaskHasBeenApproved": "Your task \"<%= taskText %>\" has been approved", "yourTaskHasBeenApproved": "Your task <span class=\"notification-green\"><%= taskText %></span> has been approved.",
"userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>", "taskNeedsWork": "<span class=\"notification-bold\"><%= managerName %></span> marked <span class=\"notification-bold\"><%= taskText %></span> as needing additional work.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%= user %></span> requests approval for <span class=\"notification-bold\"><%= taskName %></span>",
"approve": "Approve", "approve": "Approve",
"approveTask": "Approve Task",
"needsWork": "Needs Work",
"viewRequests": "View Requests",
"approvalTitle": "<%= userName %> has completed <%= type %>: \"<%= text %>\"", "approvalTitle": "<%= userName %> has completed <%= type %>: \"<%= text %>\"",
"confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?", "confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?",
"groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member", "groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member",

View File

@@ -66,5 +66,7 @@
"messageNotificationNotFound": "Notification not found.", "messageNotificationNotFound": "Notification not found.",
"notificationsRequired": "Notification ids are required.", "notificationsRequired": "Notification ids are required.",
"unallocatedStatsPoints": "You have <span class=\"notification-bold-blue\"><%= points %> unallocated Stat Points</span>",
"beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!" "beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!"
} }

View File

@@ -102,8 +102,9 @@
"alreadyUnlockedPart": "Full set already partially unlocked.", "alreadyUnlockedPart": "Full set already partially unlocked.",
"USD": "(USD)", "USD": "(USD)",
"newStuff": "New Stuff by [Bailey](https://twitter.com/Mihakuu)", "newStuff": "New Stuff by <a href=\"https://twitter.com/Mihakuu\" target=\"_blank\">Bailey</a>",
"cool": "Tell Me Later", "newBaileyUpdate": "New Bailey Update!",
"tellMeLater": "Tell Me Later",
"dismissAlert": "Dismiss This Alert", "dismissAlert": "Dismiss This Alert",
"donateText1": "Adds 20 Gems to your account. Gems are used to buy special in-game items, such as shirts and hairstyles.", "donateText1": "Adds 20 Gems to your account. Gems are used to buy special in-game items, such as shirts and hairstyles.",
"donateText2": "Help support Habitica", "donateText2": "Help support Habitica",

View File

@@ -25,6 +25,7 @@
"questInvitation": "Quest Invitation: ", "questInvitation": "Quest Invitation: ",
"questInvitationTitle": "Quest Invitation", "questInvitationTitle": "Quest Invitation",
"questInvitationInfo": "Invitation for the Quest <%= quest %>", "questInvitationInfo": "Invitation for the Quest <%= quest %>",
"invitedToQuest": "You were invited to the Quest <span class=\"notification-bold-blue\"><%= quest %></span>",
"askLater": "Ask Later", "askLater": "Ask Later",
"questLater": "Quest Later", "questLater": "Quest Later",
"buyQuest": "Buy Quest", "buyQuest": "Buy Quest",

View File

@@ -172,6 +172,7 @@
"habitCounterDown": "Negative Counter (Resets <%= frequency %>)", "habitCounterDown": "Negative Counter (Resets <%= frequency %>)",
"taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested", "taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested",
"taskApprovalHasBeenRequested": "Approval has been requested", "taskApprovalHasBeenRequested": "Approval has been requested",
"taskApprovalWasNotRequested": "Only a task waiting for approval can be marked as needing more work",
"approvals": "Approvals", "approvals": "Approvals",
"approvalRequired": "Needs Approval", "approvalRequired": "Needs Approval",
"repeatZero": "Daily is never due", "repeatZero": "Daily is never due",

View File

@@ -399,9 +399,16 @@ spells.special = {
} }
if (!target.items.special.nyeReceived) target.items.special.nyeReceived = []; if (!target.items.special.nyeReceived) target.items.special.nyeReceived = [];
target.items.special.nyeReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.nyeReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'nye',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -427,9 +434,16 @@ spells.special = {
} }
if (!target.items.special.valentineReceived) target.items.special.valentineReceived = []; if (!target.items.special.valentineReceived) target.items.special.valentineReceived = [];
target.items.special.valentineReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.valentineReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'valentine',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -445,6 +459,7 @@ spells.special = {
notes: t('greetingCardNotes'), notes: t('greetingCardNotes'),
cast (user, target) { cast (user, target) {
if (user === target) { if (user === target) {
if (!user.achievements.greeting) user.achievements.greeting = 0;
user.achievements.greeting++; user.achievements.greeting++;
} else { } else {
each([user, target], (u) => { each([user, target], (u) => {
@@ -454,9 +469,16 @@ spells.special = {
} }
if (!target.items.special.greetingReceived) target.items.special.greetingReceived = []; if (!target.items.special.greetingReceived) target.items.special.greetingReceived = [];
target.items.special.greetingReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.greetingReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'greeting',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -482,9 +504,16 @@ spells.special = {
} }
if (!target.items.special.thankyouReceived) target.items.special.thankyouReceived = []; if (!target.items.special.thankyouReceived) target.items.special.thankyouReceived = [];
target.items.special.thankyouReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.thankyouReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'thankyou',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -510,9 +539,16 @@ spells.special = {
} }
if (!target.items.special.birthdayReceived) target.items.special.birthdayReceived = []; if (!target.items.special.birthdayReceived) target.items.special.birthdayReceived = [];
target.items.special.birthdayReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.birthdayReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'birthday',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -538,9 +574,16 @@ spells.special = {
} }
if (!target.items.special.congratsReceived) target.items.special.congratsReceived = []; if (!target.items.special.congratsReceived) target.items.special.congratsReceived = [];
target.items.special.congratsReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.congratsReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'congrats',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -566,9 +609,16 @@ spells.special = {
} }
if (!target.items.special.getwellReceived) target.items.special.getwellReceived = []; if (!target.items.special.getwellReceived) target.items.special.getwellReceived = [];
target.items.special.getwellReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.getwellReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'getwell',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;
@@ -594,9 +644,16 @@ spells.special = {
} }
if (!target.items.special.goodluckReceived) target.items.special.goodluckReceived = []; if (!target.items.special.goodluckReceived) target.items.special.goodluckReceived = [];
target.items.special.goodluckReceived.push(user.profile.name); const senderName = user.profile.name;
target.items.special.goodluckReceived.push(senderName);
if (!target.flags) target.flags = {}; if (target.addNotification) target.addNotification('CARD_RECEIVED', {
card: 'goodluck',
from: {
id: user._id,
name: senderName,
},
});
target.flags.cardReceived = true; target.flags.cardReceived = true;
user.stats.gp -= 10; user.stats.gp -= 10;

View File

@@ -43,7 +43,6 @@ module.exports = function buyMysterySet (user, req = {}, analytics) {
user.purchased.plan.consecutive.trinkets--; user.purchased.plan.consecutive.trinkets--;
return [ return [
{ items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive }, { items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive },
i18n.t('hourglassPurchaseSet', req.language), i18n.t('hourglassPurchaseSet', req.language),

View File

@@ -5,13 +5,24 @@ import {
} from '../libs/errors'; } from '../libs/errors';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
function markNotificationAsRead (user) {
const index = user.notifications.findIndex(notification => {
return notification.type === 'NEW_MYSTERY_ITEMS';
});
if (index !== -1) user.notifications.splice(index, 1);
}
module.exports = function openMysteryItem (user, req = {}, analytics) { module.exports = function openMysteryItem (user, req = {}, analytics) {
let item = user.purchased.plan.mysteryItems.shift(); const mysteryItems = user.purchased.plan.mysteryItems;
let item = mysteryItems.shift();
if (!item) { if (!item) {
throw new BadRequest(i18n.t('mysteryItemIsEmpty', req.language)); throw new BadRequest(i18n.t('mysteryItemIsEmpty', req.language));
} }
if (mysteryItems.length === 0) markNotificationAsRead(user);
item = cloneDeep(content.gear.flat[item]); item = cloneDeep(content.gear.flat[item]);
user.items.gear.owned[item.key] = true; user.items.gear.owned[item.key] = true;

View File

@@ -7,6 +7,19 @@ import {
} from '../libs/errors'; } from '../libs/errors';
import content from '../content/index'; import content from '../content/index';
// @TODO move in the servercontroller or keep here?
function markNotificationAsRead (user, cardType) {
const indexToRemove = user.notifications.findIndex(notification => {
if (
notification.type === 'CARD_RECEIVED' &&
notification.data.card === cardType
) return true;
});
if (indexToRemove !== -1) user.notifications.splice(indexToRemove, 1);
}
module.exports = function readCard (user, req = {}) { module.exports = function readCard (user, req = {}) {
let cardType = get(req.params, 'cardType'); let cardType = get(req.params, 'cardType');
@@ -21,6 +34,8 @@ module.exports = function readCard (user, req = {}) {
user.items.special[`${cardType}Received`].shift(); user.items.special[`${cardType}Received`].shift();
user.flags.cardReceived = false; user.flags.cardReceived = false;
markNotificationAsRead(user, cardType);
return [ return [
{ specialItems: user.items.special, cardReceived: user.flags.cardReceived }, { specialItems: user.items.special, cardReceived: user.flags.cardReceived },
i18n.t('readCard', {cardType}, req.language), i18n.t('readCard', {cardType}, req.language),

View File

@@ -489,9 +489,31 @@ api.seenChat = {
// let group = await Group.getGroup({user, groupId}); // let group = await Group.getGroup({user, groupId});
// if (!group) throw new NotFound(res.t('groupNotFound')); // if (!group) throw new NotFound(res.t('groupNotFound'));
let update = {$unset: {}}; let update = {
$unset: {},
$pull: {},
};
update.$unset[`newMessages.${groupId}`] = true; update.$unset[`newMessages.${groupId}`] = true;
update.$pull.notifications = {
type: 'NEW_CHAT_MESSAGE',
'data.group.id': groupId,
};
// Remove from response
user.notifications = user.notifications.filter(n => {
if (n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupId) {
return false;
}
return true;
});
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
await User.update({_id: user._id}, update).exec(); await User.update({_id: user._id}, update).exec();
res.respond(200, {}); res.respond(200, {});
}, },

View File

@@ -701,6 +701,14 @@ function _removeMessagesFromMember (member, groupId) {
delete member.newMessages[groupId]; delete member.newMessages[groupId];
member.markModified('newMessages'); member.markModified('newMessages');
} }
member.notifications = member.notifications.filter(n => {
if (n.type === 'NEW_CHAT_MESSAGE' && n.data.group && n.data.group.id === groupId) {
return false;
}
return true;
});
} }
/** /**
@@ -917,6 +925,7 @@ api.removeGroupMember = {
async function _inviteByUUID (uuid, group, inviter, req, res) { async function _inviteByUUID (uuid, group, inviter, req, res) {
let userToInvite = await User.findById(uuid).exec(); let userToInvite = await User.findById(uuid).exec();
const publicGuild = group.type === 'guild' && group.privacy === 'public';
if (!userToInvite) { if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
@@ -932,7 +941,12 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
} }
let guildInvite = {id: group._id, name: group.name, inviter: inviter._id}; let guildInvite = {
id: group._id,
name: group.name,
inviter: inviter._id,
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true; if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite); userToInvite.invitations.guilds.push(guildInvite);
} else if (group.type === 'party') { } else if (group.type === 'party') {
@@ -985,7 +999,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
title: group.name, title: group.name,
message: res.t(identifier), message: res.t(identifier),
identifier, identifier,
payload: {groupID: group._id}, payload: {groupID: group._id, publicGuild},
} }
); );
} }
@@ -1022,6 +1036,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
const groupQueryString = JSON.stringify({ const groupQueryString = JSON.stringify({
id: group._id, id: group._id,
inviter: inviter._id, inviter: inviter._id,
publicGuild: group.type === 'guild' && group.privacy === 'public',
sentAt: Date.now(), // so we can let it expire sentAt: Date.now(), // so we can let it expire
cancelledPlan, cancelledPlan,
}); });
@@ -1262,7 +1277,9 @@ api.removeGroupManager = {
let manager = await User.findById(managerId, 'notifications').exec(); let manager = await User.findById(managerId, 'notifications').exec();
let newNotifications = manager.notifications.filter((notification) => { let newNotifications = manager.notifications.filter((notification) => {
return notification.type !== 'GROUP_TASK_APPROVAL'; const isGroupTaskNotification = notification.type.indexOf('GROUP_TASK_') === 0;
return !isGroupTaskNotification;
}); });
manager.notifications = newNotifications; manager.notifications = newNotifications;
manager.markModified('notifications'); manager.markModified('notifications');

View File

@@ -0,0 +1,125 @@
import { authWithHeaders } from '../../middlewares/auth';
let api = {};
// @TODO export this const, cannot export it from here because only routes are exported from controllers
const LAST_ANNOUNCEMENT_TITLE = 'HABITICA BIRTHDAY CELEBRATION, LAST CHANCE FOR WINTER WONDERLAND ITEMS, AND CONTINUED RESOLUTION PLOT-LINE';
const worldDmg = { // @TODO
bailey: false,
};
/**
* @api {get} /api/v3/news Get latest Bailey announcement
* @apiName GetNews
* @apiGroup News
*
*
* @apiSuccess {Object} html Latest Bailey html
*
*/
api.getNews = {
method: 'GET',
url: '/news',
async handler (req, res) {
const baileyClass = worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey';
res.status(200).send({
html: `
<div class="bailey">
<div class="media">
<div class="align-self-center mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center markdown">${res.t('newStuff')}</h1>
</div>
</div>
<h2>1/30/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<hr/>
<div class="promo_habit_birthday_2018 center-block"></div>
<h3>Habitica Birthday Party!</h3>
<p>January 31st is Habitica's Birthday! Thank you so much for being a part of our community - it means a lot.</p>
<p>Now come join us and the NPCs as we celebrate!</p>
<h4>Cake for Everybody!</h4>
<p>In honor of the festivities, everyone has been awarded an assortment of yummy cake to feed to your pets! Plus, for the next two days
<a href="/shops/market" target="_blank" rel="noopener">Alexander the Merchant</a>
is selling cake in his shop, and cake will sometimes drop when you complete your tasks. Cake works just like normal pet food,
but if you want to know what type of pet likes each slice, <a href="http://habitica.wikia.com/wiki/Food" target="_blank" rel="noopener">the wiki has spoilers</a>.
</p>
<h4>Party Robes</h4>
<p>There are Party Robes available for free in the Rewards column! Don them with pride.</p>
<h4>Birthday Bash Achievement</h4>
<p>In honor of Habitica's birthday, everyone has been awarded the Habitica Birthday Bash achievement! This achievement stacks for each Birthday Bash you celebrate with us.</p>
<div class="media">
<div class="media-body">
<h3>Last Chance for Frost Sprite Set</h3>
<p class="markdown">Reminder: this is the final day to <a href="/user/settings/subscription" target="_blank" rel="noopener">subscribe</a>
and receive the Frost Sprite Set! Subscribing also lets you buy gems for gold. The longer your subscription, the more gems you get!</p>
<p>Thanks so much for your support! You help keep Habitica running.</p>
<div class="small mb-3">by Beffymaroo</div>
<h3>Last Chance for Starry Night and Holly Hatching Potions</h3>
<p class="markdown">Reminder: this is the final day to <a href="/shops/market" target="_blank" rel="noopener">buy Starry Night and Holly Hatching Potions!</a> If they come back, it won't be until next year at the earliest, so don't delay!</p>
<div class="small mb-3">by Vampitch, JinjooHat, Lemoness, and SabreCat</div>
<h3>Resolution Plot-Line: Broken Buildings</h3>
<p>Lemoness, SabreCat, and Beffymaroo call an important meeting to address the rumors that are flying about this strange outbreak of Habiticans who are suddenly losing all faith in their ability to complete their New Year's Resolutions.</p>
<p>“Thank you all for coming,” Lemoness says. “I'm afraid that we have some very serious news to share, but we ask that you remain calm.”</p>
</div>
<div class="promo_starry_potions ml-3"></div>
</div>
<p>“While it's natural to feel a little disheartened as the end of January approaches,” Beffymaroo says, “these sudden outbreaks appear to have some strange magical origin.
We're still investigating the exact cause, but we do know that the buildings where the affected Habiticans live often seem to sustain some damage immediately before the attack.”</p>
<p>SabreCat clears his throat. “For this reason, we strongly encourage everyone to stay away from broken-down structures, and if you feel any strange tremors or hear odd sounds,
please report them immediately.”</p>
<p class="markdown">“Stay safe, Habiticans.” Lemoness flashes her best comforting smile.
“And remember that if your New Year's Resolution goals seem daunting, you can always seek support in the
<a href="https://habitica.com/groups/guild/6e6a8bd3-9f5f-4351-9188-9f11fcd80a99" target="_blank" rel="noopener">New Year's Resolution Guild</a>.”</p>
<p>How mysterious! Hopefully they'll get to the bottom of this soon.</p>
<hr>
</div>
`,
});
},
};
/**
* @api {post} /api/v3/news/tell-me-later Get latest Bailey announcement in a second moment
* @apiName TellMeLaterNews
* @apiGroup News
*
*
* @apiSuccess {Object} data An empty Object
*
*/
api.tellMeLaterNews = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/news/tell-me-later',
async handler (req, res) {
const user = res.locals.user;
user.flags.newStuff = false;
const existingNotificationIndex = user.notifications.findIndex(n => {
return n.type === 'NEW_STUFF';
});
if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1);
user.addNotification('NEW_STUFF', { title: LAST_ANNOUNCEMENT_TITLE }, true); // seen by default
await user.save();
res.respond(200, {});
},
};
module.exports = api;

View File

@@ -3,11 +3,13 @@ import _ from 'lodash';
import { import {
NotFound, NotFound,
} from '../../libs/errors'; } from '../../libs/errors';
import {
model as User,
} from '../../models/user';
let api = {}; let api = {};
/** /**
* @apiIgnore Not yet part of the public API
* @api {post} /api/v3/notifications/:notificationId/read Mark one notification as read * @api {post} /api/v3/notifications/:notificationId/read Mark one notification as read
* @apiName ReadNotification * @apiName ReadNotification
* @apiGroup Notification * @apiGroup Notification
@@ -38,6 +40,11 @@ api.readNotification = {
user.notifications.splice(index, 1); user.notifications.splice(index, 1);
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
await user.update({ await user.update({
$pull: { notifications: { id: req.params.notificationId } }, $pull: { notifications: { id: req.params.notificationId } },
}).exec(); }).exec();
@@ -47,12 +54,10 @@ api.readNotification = {
}; };
/** /**
* @apiIgnore Not yet part of the public API * @api {post} /api/v3/notifications/read Mark multiple notifications as read
* @api {post} /api/v3/notifications Mark notifications as read
* @apiName ReadNotifications * @apiName ReadNotifications
* @apiGroup Notification * @apiGroup Notification
* *
*
* @apiSuccess {Object} data user.notifications * @apiSuccess {Object} data user.notifications
*/ */
api.readNotifications = { api.readNotifications = {
@@ -84,6 +89,102 @@ api.readNotifications = {
$pull: { notifications: { id: { $in: notifications } } }, $pull: { notifications: { id: { $in: notifications } } },
}).exec(); }).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, user.notifications);
},
};
/**
* @api {post} /api/v3/notifications/:notificationId/see Mark one notification as seen
* @apiDescription Mark a notification as seen. Different from marking them as read in that the notification isn't removed but the `seen` field is set to `true`
* @apiName SeeNotification
* @apiGroup Notification
*
* @apiParam (Path) {UUID} notificationId
*
* @apiSuccess {Object} data The modified notification
*/
api.seeNotification = {
method: 'POST',
url: '/notifications/:notificationId/see',
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;
const notificationId = req.params.notificationId;
let notification = _.find(user.notifications, {
id: notificationId,
});
if (!notification) {
throw new NotFound(res.t('messageNotificationNotFound'));
}
notification.seen = true;
await User.update({
_id: user._id,
'notifications.id': notificationId,
}, {
$set: {
'notifications.$.seen': true,
},
}).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, notification);
},
};
/**
* @api {post} /api/v3/notifications/see Mark multiple notifications as seen
* @apiName SeeNotifications
* @apiGroup Notification
*
* @apiSuccess {Object} data user.notifications
*/
api.seeNotifications = {
method: 'POST',
url: '/notifications/see',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkBody('notificationIds', res.t('notificationsRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let notificationsIds = req.body.notificationIds;
for (let notificationId of notificationsIds) {
let notification = _.find(user.notifications, {
id: notificationId,
});
if (!notification) {
throw new NotFound(res.t('messageNotificationNotFound'));
}
notification.seen = true;
}
await user.save();
res.respond(200, user.notifications); res.respond(200, user.notifications);
}, },
}; };

View File

@@ -225,6 +225,11 @@ api.deleteTag = {
$pull: { tags: { id: tagFound.id } }, $pull: { tags: { id: tagFound.id } },
}).exec(); }).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
// Remove from all the tasks TODO test // Remove from all the tasks TODO test
await Tasks.Task.update({ await Tasks.Task.update({
userId: user._id, userId: user._id,

View File

@@ -589,7 +589,9 @@ api.scoreTask = {
taskName: task.text, taskName: task.text,
}, manager.preferences.language), }, manager.preferences.language),
groupId: group._id, groupId: group._id,
taskId: task._id, taskId: task._id, // user task id, used to match the notification when the task is approved
userId: user._id,
groupTaskId: task.group.id, // the original task id
direction, direction,
}); });
managerPromises.push(manager.save()); managerPromises.push(manager.save());
@@ -729,7 +731,7 @@ api.moveTask = {
moveTask(order, task._id, to); moveTask(order, task._id, to);
// Server updates // Server updates
// @TODO: maybe bulk op? // Cannot send $pull and $push on same field in one single op
let pullQuery = { $pull: {} }; let pullQuery = { $pull: {} };
pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id; pullQuery.$pull[`tasksOrder.${task.type}s`] = task.id;
await user.update(pullQuery).exec(); await user.update(pullQuery).exec();
@@ -745,6 +747,11 @@ api.moveTask = {
}; };
await user.update(updateQuery).exec(); await user.update(updateQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
user._v++;
res.respond(200, order); res.respond(200, order);
}, },
}; };
@@ -1305,6 +1312,11 @@ api.deleteTask = {
pullQuery.$pull[`tasksOrder.${task.type}s`] = task._id; pullQuery.$pull[`tasksOrder.${task.type}s`] = task._id;
let taskOrderUpdate = (challenge || user).update(pullQuery).exec(); let taskOrderUpdate = (challenge || user).update(pullQuery).exec();
// Update the user version field manually,
// it cannot be updated in the pre update hook
// See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info
if (!challenge) user._v++;
await Bluebird.all([taskOrderUpdate, task.remove()]); await Bluebird.all([taskOrderUpdate, task.remove()]);
} else { } else {
await task.remove(); await task.remove();

View File

@@ -317,7 +317,7 @@ api.approveTask = {
// Get task direction // Get task direction
const firstManagerNotifications = managers[0].notifications; const firstManagerNotifications = managers[0].notifications;
const firstNotificationIndex = findIndex(firstManagerNotifications, (notification) => { const firstNotificationIndex = findIndex(firstManagerNotifications, (notification) => {
return notification.data.taskId === task._id; return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
}); });
let direction = 'up'; let direction = 'up';
if (firstManagerNotifications[firstNotificationIndex]) { if (firstManagerNotifications[firstNotificationIndex]) {
@@ -328,7 +328,7 @@ api.approveTask = {
let managerPromises = []; let managerPromises = [];
managers.forEach((manager) => { managers.forEach((manager) => {
let notificationIndex = findIndex(manager.notifications, function findNotification (notification) { let notificationIndex = findIndex(manager.notifications, function findNotification (notification) {
return notification.data.taskId === task._id; return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
}); });
if (notificationIndex !== -1) { if (notificationIndex !== -1) {
@@ -357,6 +357,105 @@ api.approveTask = {
}, },
}; };
/**
* @api {post} /api/v3/tasks/:taskId/needs-work/:userId Group task needs more work
* @apiDescription Mark an assigned group task as needeing more work before it can be approved
* @apiVersion 3.0.0
* @apiName TaskNeedsWork
* @apiGroup Task
*
* @apiParam (Path) {UUID} taskId The id of the task that is the original group task
* @apiParam (Path) {UUID} userId The id of the assigned user
*
* @apiSuccess task The task that needs more work
*/
api.taskNeedsWork = {
method: 'POST',
url: '/tasks/:taskId/needs-work/:userId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let user = res.locals.user;
let assignedUserId = req.params.userId;
let taskId = req.params.taskId;
const [assignedUser, task] = await Promise.all([
User.findById(assignedUserId).exec(),
await Tasks.Task.findOne({
'group.taskId': taskId,
userId: assignedUserId,
}).exec(),
]);
if (!task) {
throw new NotFound(res.t('taskNotFound'));
}
let fields = requiredGroupFields.concat(' managers');
let group = await Group.getGroup({user, groupId: task.group.id, fields});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
if (task.group.approval.approved === true) throw new NotAuthorized(res.t('canOnlyApproveTaskOnce'));
if (!task.group.approval.requested) {
throw new NotAuthorized(res.t('taskApprovalWasNotRequested'));
}
// Get Managers
const managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
const managers = await User.find({_id: managerIds}, 'notifications').exec(); // Use this method so we can get access to notifications
const promises = [];
// Remove old notifications
managers.forEach((manager) => {
let notificationIndex = findIndex(manager.notifications, function findNotification (notification) {
return notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
});
if (notificationIndex !== -1) {
manager.notifications.splice(notificationIndex, 1);
promises.push(manager.save());
}
});
task.group.approval.requested = false;
task.group.approval.requestedDate = undefined;
const taskText = task.text;
const managerName = user.profile.name;
const message = res.t('taskNeedsWork', {taskText, managerName}, assignedUser.preferences.language);
assignedUser.addNotification('GROUP_TASK_NEEDS_WORK', {
message,
task: {
id: task._id,
text: taskText,
},
group: {
id: group._id,
name: group.name,
},
manager: {
id: user._id,
name: managerName,
},
});
await Promise.all([...promises, assignedUser.save(), task.save()]);
res.respond(200, task);
},
};
/** /**
* @api {get} /api/v3/approvals/group/:groupId Get a group's approvals * @api {get} /api/v3/approvals/group/:groupId Get a group's approvals
* @apiVersion 3.0.0 * @apiVersion 3.0.0

View File

@@ -457,6 +457,7 @@ function _cleanChecklist (task) {
* Contributor information * Contributor information
* Special items * Special items
* Webhooks * Webhooks
* Notifications
* *
* @apiSuccess {Object} data.user * @apiSuccess {Object} data.user
* @apiSuccess {Object} data.tasks * @apiSuccess {Object} data.tasks
@@ -486,6 +487,7 @@ api.getUserAnonymized = {
delete user.items.special.valentineReceived; delete user.items.special.valentineReceived;
delete user.webhooks; delete user.webhooks;
delete user.achievements.challenges; delete user.achievements.challenges;
delete user.notifications;
_.forEach(user.inbox.messages, (msg) => { _.forEach(user.inbox.messages, (msg) => {
msg.text = 'inbox message text'; msg.text = 'inbox message text';
@@ -653,6 +655,7 @@ api.castSpell = {
}) })
// .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save // .select(partyMembersFields) Selecting the entire user because otherwise when saving it'll save
// default values for non-selected fields and pre('save') will mess up thinking some values are missing // default values for non-selected fields and pre('save') will mess up thinking some values are missing
// and we need target.notifications to add the notification for the received card
.exec(); .exec();
partyMembers.unshift(user); partyMembers.unshift(user);

View File

@@ -33,6 +33,8 @@ api.constants = {
}; };
function revealMysteryItems (user) { function revealMysteryItems (user) {
const pushedItems = [];
_.each(shared.content.gear.flat, function findMysteryItems (item) { _.each(shared.content.gear.flat, function findMysteryItems (item) {
if ( if (
item.klass === 'mystery' && item.klass === 'mystery' &&
@@ -42,8 +44,11 @@ function revealMysteryItems (user) {
user.purchased.plan.mysteryItems.indexOf(item.key) === -1 user.purchased.plan.mysteryItems.indexOf(item.key) === -1
) { ) {
user.purchased.plan.mysteryItems.push(item.key); user.purchased.plan.mysteryItems.push(item.key);
pushedItems.push(item.key);
} }
}); });
user.addNotification('NEW_MYSTERY_ITEMS', { items: pushedItems });
} }
function _dateDiff (earlyDate, lateDate) { function _dateDiff (earlyDate, lateDate) {
@@ -62,9 +67,9 @@ function _dateDiff (earlyDate, lateDate) {
api.addSubscriptionToGroupUsers = async function addSubscriptionToGroupUsers (group) { api.addSubscriptionToGroupUsers = async function addSubscriptionToGroupUsers (group) {
let members; let members;
if (group.type === 'guild') { if (group.type === 'guild') {
members = await User.find({guilds: group._id}).select('_id purchased items auth profile.name').exec(); members = await User.find({guilds: group._id}).select('_id purchased items auth profile.name notifications').exec();
} else { } else {
members = await User.find({'party._id': group._id}).select('_id purchased items auth profile.name').exec(); members = await User.find({'party._id': group._id}).select('_id purchased items auth profile.name notifications').exec();
} }
let promises = members.map((member) => { let promises = members.map((member) => {

View File

@@ -31,6 +31,7 @@ import {
} from './subscriptionPlan'; } from './subscriptionPlan';
import amazonPayments from '../libs/amazonPayments'; import amazonPayments from '../libs/amazonPayments';
import stripePayments from '../libs/stripePayments'; import stripePayments from '../libs/stripePayments';
import { model as UserNotification } from './userNotification';
const questScrolls = shared.content.quests; const questScrolls = shared.content.quests;
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
@@ -493,10 +494,8 @@ schema.methods.sendChat = function sendChat (message, user, metaData) {
} }
// Kick off chat notifications in the background. // Kick off chat notifications in the background.
let lastSeenUpdate = {$set: {
[`newMessages.${this._id}`]: {name: this.name, value: true}, const query = {};
}};
let query = {};
if (this.type === 'party') { if (this.type === 'party') {
query['party._id'] = this._id; query['party._id'] = this._id;
@@ -506,7 +505,29 @@ schema.methods.sendChat = function sendChat (message, user, metaData) {
query._id = { $ne: user ? user._id : ''}; query._id = { $ne: user ? user._id : ''};
User.update(query, lastSeenUpdate, {multi: true}).exec(); // First remove the old notification (if it exists)
const lastSeenUpdateRemoveOld = {
$pull: {
notifications: { type: 'NEW_CHAT_MESSAGE', 'data.group.id': this._id },
},
};
// Then add the new notification
const lastSeenUpdateAddNew = {
$set: { // old notification, supported until mobile is updated and we release api v4
[`newMessages.${this._id}`]: {name: this.name, value: true},
},
$push: {
notifications: new UserNotification({
type: 'NEW_CHAT_MESSAGE',
data: { group: { id: this._id, name: this.name } },
}).toObject(),
},
};
User.update(query, lastSeenUpdateRemoveOld, {multi: true}).exec().then(() => {
User.update(query, lastSeenUpdateAddNew, {multi: true}).exec();
});
// If the message being sent is a system message (not gone through the api.postChat controller) // If the message being sent is a system message (not gone through the api.postChat controller)
// then notify Pusher about it (only parties for now) // then notify Pusher about it (only parties for now)

View File

@@ -247,6 +247,43 @@ schema.pre('save', true, function preSaveUser (next, done) {
// this.items.pets['JackOLantern-Base'] = 5; // this.items.pets['JackOLantern-Base'] = 5;
} }
// Manage unallocated stats points notifications
if (this.isSelected('stats') && this.isSelected('notifications')) {
const pointsToAllocate = this.stats.points;
// Sometimes there can be more than 1 notification
const existingNotifications = this.notifications.filter(notification => {
return notification.type === 'UNALLOCATED_STATS_POINTS';
});
const existingNotificationsLength = existingNotifications.length;
// Take the most recent notification
const lastExistingNotification = existingNotificationsLength > 0 ? existingNotifications[existingNotificationsLength - 1] : null;
// Decide if it's outdated or not
const outdatedNotification = !lastExistingNotification || lastExistingNotification.data.points !== pointsToAllocate;
// If the notification is outdated, remove all the existing notifications, otherwise all of them except the last
let notificationsToRemove = outdatedNotification ? existingNotificationsLength : existingNotificationsLength - 1;
// If there are points to allocate and the notification is outdated, add a new notifications
if (pointsToAllocate > 0 && outdatedNotification) {
this.addNotification('UNALLOCATED_STATS_POINTS', { points: pointsToAllocate });
}
// Remove the outdated notifications
if (notificationsToRemove > 0) {
let notificationsRemoved = 0;
this.notifications = this.notifications.filter(notification => {
if (notification.type !== 'UNALLOCATED_STATS_POINTS') return true;
if (notificationsRemoved === notificationsToRemove) return true;
notificationsRemoved++;
return false;
});
}
}
// Enable weekly recap emails for old users who sign in // Enable weekly recap emails for old users who sign in
if (this.flags.lastWeeklyRecapDiscriminator) { if (this.flags.lastWeeklyRecapDiscriminator) {
// Enable weekly recap emails in 24 hours // Enable weekly recap emails in 24 hours

View File

@@ -106,6 +106,28 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
userToReceiveMessage._v++; userToReceiveMessage._v++;
userToReceiveMessage.markModified('inbox.messages'); userToReceiveMessage.markModified('inbox.messages');
/* @TODO disabled until mobile is ready
let excerpt;
if (!options.receiverMsg) {
excerpt = '';
} else if (options.receiverMsg.length < 100) {
excerpt = options.receiverMsg;
} else {
excerpt = options.receiverMsg.substring(0, 100);
}
userToReceiveMessage.addNotification('NEW_INBOX_MESSAGE', {
sender: {
id: sender._id,
name: sender.profile.name,
},
excerpt,
messageId: newMessage.id,
});
*/
common.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(senderMsg, userToReceiveMessage))); common.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(senderMsg, userToReceiveMessage)));
sender.markModified('inbox.messages'); sender.markModified('inbox.messages');
@@ -119,11 +141,13 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
* *
* @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema * @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema
* @param data The data to add to the notification * @param data The data to add to the notification
* @param seen If the notification should be marked as seen
*/ */
schema.methods.addNotification = function addUserNotification (type, data = {}) { schema.methods.addNotification = function addUserNotification (type, data = {}, seen = false) {
this.notifications.push({ this.notifications.push({
type, type,
data, data,
seen,
}); });
}; };
@@ -137,13 +161,13 @@ schema.methods.addNotification = function addUserNotification (type, data = {})
* @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema * @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema
* @param data The data to add to the notification * @param data The data to add to the notification
*/ */
schema.statics.pushNotification = async function pushNotification (query, type, data = {}) { schema.statics.pushNotification = async function pushNotification (query, type, data = {}, seen = false) {
let newNotification = new UserNotification({type, data}); let newNotification = new UserNotification({type, data, seen});
let validationResult = newNotification.validateSync(); let validationResult = newNotification.validateSync();
if (validationResult) { if (validationResult) {
throw validationResult; throw validationResult;
} }
await this.update(query, {$push: {notifications: newNotification}}, {multi: true}).exec(); await this.update(query, {$push: {notifications: newNotification.toObject()}}, {multi: true}).exec();
}; };
// Add stats.toNextLevel, stats.maxMP and stats.maxHealth // Add stats.toNextLevel, stats.maxMP and stats.maxHealth

View File

@@ -377,7 +377,7 @@ let schema = new Schema({
invitations: { invitations: {
// Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default // Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default
// Schema is (id, name, inviter) // Schema is (id, name, inviter, publicGuild)
// TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id // TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id
guilds: {type: Array, default: () => []}, guilds: {type: Array, default: () => []},
// Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO // Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO

View File

@@ -44,6 +44,8 @@ export let schema = new Schema({
data: {type: Schema.Types.Mixed, default: () => { data: {type: Schema.Types.Mixed, default: () => {
return {}; return {};
}}, }},
// A field to mark the notification as seen without deleting it, optional use
seen: {type: Boolean, required: true, default: () => false},
}, { }, {
strict: true, strict: true,
minimize: false, // So empty objects are returned minimize: false, // So empty objects are returned