Merge branch 'develop' into client-monorepo

This commit is contained in:
Matteo Pagliazzi
2019-10-18 20:26:12 +02:00
17 changed files with 292 additions and 45 deletions

View File

@@ -0,0 +1,60 @@
import mongoose from 'mongoose';
import {
highlightMentions,
} from '../../../../website/server/libs/highlightMentions';
describe('highlightMentions', () => {
beforeEach(() => {
const mockFind = {
select () {
return this;
},
lean () {
return this;
},
exec () {
return Promise.resolve([{
auth: { local: { username: 'user' } }, _id: '111',
}, { auth: { local: { username: 'user2' } }, _id: '222' }, { auth: { local: { username: 'user3' } }, _id: '333' }, { auth: { local: { username: 'user-dash' } }, _id: '444' }, { auth: { local: { username: 'user_underscore' } }, _id: '555' },
]);
},
};
sinon.stub(mongoose.Model, 'find').returns(mockFind);
});
afterEach(() => {
sinon.restore();
});
it('doesn\'t change text without mentions', async () => {
const text = 'some chat text';
const result = await highlightMentions(text);
expect(result[0]).to.equal(text);
});
it('highlights existing users', async () => {
const text = '@user: message';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@user](/profile/111): message');
});
it('highlights special characters', async () => {
const text = '@user-dash: message @user_underscore';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
});
it('doesn\'t highlight nonexisting users', async () => {
const text = '@nouser message';
const result = await highlightMentions(text);
expect(result[0]).to.equal('@nouser message');
});
it('highlights multiple existing users', async () => {
const text = '@user message (@user2) @user3 @user';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@user](/profile/111) message ([@user2](/profile/222)) [@user3](/profile/333) [@user](/profile/111)');
});
it('doesn\'t highlight more than 5 users', async () => {
const text = '@user @user2 @user3 @user4 @user5 @user6';
const result = await highlightMentions(text);
expect(result[0]).to.equal(text);
});
});

View File

@@ -386,6 +386,7 @@ describe('User Model', () => {
user = await user.save();
// verify that it's been awarded
expect(user.achievements.beastMaster).to.equal(true);
expect(user.notifications.find(notification => notification.type === 'ACHIEVEMENT_BEAST_MASTER')).to.exist;
// reset the user
user.achievements.beastMasterCount = 0;
@@ -417,6 +418,28 @@ describe('User Model', () => {
expect(user.achievements.beastMaster).to.not.equal(true);
});
it('adds achievements to notification list', async () => {
let user = new User();
user = await user.save(); // necessary for user.isSelected to work correctly
// Create conditions for achievements to be awarded
user.achievements.beastMasterCount = 3;
user.achievements.mountMasterCount = 3;
user.achievements.triadBingoCount = 3;
// verify that it was not awarded initially
expect(user.achievements.beastMaster).to.not.equal(true);
// verify that it was not awarded initially
expect(user.achievements.mountMaster).to.not.equal(true);
// verify that it was not awarded initially
expect(user.achievements.triadBingo).to.not.equal(true);
user = await user.save();
// verify that it's been awarded
expect(user.notifications.find(notification => notification.type === 'ACHIEVEMENT_BEAST_MASTER')).to.exist;
expect(user.notifications.find(notification => notification.type === 'ACHIEVEMENT_MOUNT_MASTER')).to.exist;
expect(user.notifications.find(notification => notification.type === 'ACHIEVEMENT_TRIAD_BINGO')).to.exist;
});
context('manage unallocated stats points notifications', () => {
it('doesn\'t add a notification if there are no points to allocate', async () => {
let user = new User();

View File

@@ -318,6 +318,7 @@ describe('POST /group/:groupId/join', () => {
name: 'Testing Party',
type: 'party',
});
await leader.post(`/groups/${party._id}/invite`, {
uuids: [member._id],
});
@@ -329,7 +330,9 @@ describe('POST /group/:groupId/join', () => {
await leader.sync();
expect(member).to.have.nested.property('achievements.partyUp', true);
expect(member.notifications.find(notification => notification.type === 'ACHIEVEMENT_PARTY_UP')).to.exist;
expect(leader).to.have.nested.property('achievements.partyUp', true);
expect(leader.notifications.find(notification => notification.type === 'ACHIEVEMENT_PARTY_UP')).to.exist;
});
it('does not award Party On achievement to party of size 2', async () => {
@@ -353,7 +356,9 @@ describe('POST /group/:groupId/join', () => {
await leader.sync();
expect(member).to.have.nested.property('achievements.partyOn', true);
expect(member.notifications.find(notification => notification.type === 'ACHIEVEMENT_PARTY_ON')).to.exist;
expect(leader).to.have.nested.property('achievements.partyOn', true);
expect(leader.notifications.find(notification => notification.type === 'ACHIEVEMENT_PARTY_ON')).to.exist;
});
});
});

View File

@@ -33,6 +33,7 @@
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p>
<div
ref="markdownContainer"
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
@@ -139,7 +140,6 @@
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.mentioned-icon {
width: 16px;
@@ -313,6 +313,16 @@ export default {
},
},
mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
for (let i = 0; i < links.length; i += 1) {
const link = links[i];
if (links[i].getAttribute('href').startsWith('/profile/')) {
links[i].onclick = ev => {
ev.preventDefault();
this.$router.push({ path: link.getAttribute('href') });
};
}
}
this.CHAT_FLAG_LIMIT_FOR_HIDING = CHAT_FLAG_LIMIT_FOR_HIDING;
this.CHAT_FLAG_FROM_SHADOW_MUTE = CHAT_FLAG_FROM_SHADOW_MUTE;
this.$emit('chat-card-mounted', this.msg.id);

View File

@@ -563,7 +563,6 @@ export default {
if (this.isParty) {
await this.$store.dispatch('party:getParty', true);
this.group = this.$store.state.party.data;
this.checkForAchievements();
} else {
const group = await this.$store.dispatch('guilds:getGroup', { groupId: this.searchId });
this.$set(this, 'group', group);
@@ -584,21 +583,6 @@ export default {
return this.user.notifications.some(n => n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupId);
},
checkForAchievements () {
// Checks if user's party has reached 2 players for the first time.
if (!this.user.achievements.partyUp && this.group.memberCount >= 2) {
// @TODO
// User.set({'achievements.partyUp':true});
// Achievement.displayAchievement('partyUp');
}
// Checks if user's party has reached 4 players for the first time.
if (!this.user.achievements.partyOn && this.group.memberCount >= 4) {
// @TODO
// User.set({'achievements.partyOn':true});
// Achievement.displayAchievement('partyOn');
}
},
async join () {
if (this.group.cancelledPlan && !window.confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return;

View File

@@ -175,6 +175,43 @@ const NOTIFICATIONS = {
label: $t => `${$t('achievement')}: ${$t('achievementAridAuthority')}`,
modalId: 'generic-achievement',
},
ACHIEVEMENT_PARTY_UP: {
achievement: true,
label: $t => `${$t('achievement')}: ${$t('achievementPartyUp')}`,
modalId: 'generic-achievement',
},
ACHIEVEMENT_PARTY_ON: {
achievement: true,
label: $t => `${$t('achievement')}: ${$t('achievementPartyOn')}`,
modalId: 'generic-achievement',
},
ACHIEVEMENT_BEAST_MASTER: {
achievement: true,
label: $t => `${$t('achievement')}: ${$t('beastAchievement')}`,
modalId: 'generic-achievement',
data: {
message: $t => $t('achievement'),
modalText: $t => $t('mountAchievement'),
},
},
ACHIEVEMENT_MOUNT_MASTER: {
achievement: true,
label: $t => `${$t('achievement')}: ${$t('mountAchievement')}`,
modalId: 'generic-achievement',
data: {
message: $t => $t('achievement'),
modalText: $t => $t('mountAchievement'),
},
},
ACHIEVEMENT_TRIAD_BINGO: {
achievement: true,
label: $t => `${$t('achievement')}: ${$t('triadBingoAchievement')}`,
modalId: 'generic-achievement',
data: {
message: $t => $t('achievement'),
modalText: $t => $t('triadBingoAchievement'),
},
},
};
export default {
@@ -229,7 +266,8 @@ export default {
'ULTIMATE_GEAR_ACHIEVEMENT', 'REBIRTH_ACHIEVEMENT', 'GUILD_JOINED_ACHIEVEMENT',
'CHALLENGE_JOINED_ACHIEVEMENT', 'INVITED_FRIEND_ACHIEVEMENT', 'NEW_CONTRIBUTOR_LEVEL',
'CRON', 'SCORED_TASK', 'LOGIN_INCENTIVE', 'ACHIEVEMENT_ALL_YOUR_BASE', 'ACHIEVEMENT_BACK_TO_BASICS',
'ACHIEVEMENT_DUST_DEVIL', 'ACHIEVEMENT_ARID_AUTHORITY', 'GENERIC_ACHIEVEMENT',
'GENERIC_ACHIEVEMENT', 'ACHIEVEMENT_PARTY_UP', 'ACHIEVEMENT_PARTY_ON', 'ACHIEVEMENT_BEAST_MASTER',
'ACHIEVEMENT_MOUNT_MASTER', 'ACHIEVEMENT_TRIAD_BINGO', 'ACHIEVEMENT_DUST_DEVIL', 'ACHIEVEMENT_ARID_AUTHORITY',
].forEach(type => {
handledNotifications[type] = true;
});
@@ -380,21 +418,30 @@ export default {
if (!config) {
return;
}
if (config.achievement) {
this.playSound('Achievement_Unlocked');
} else if (config.sound) {
this.playSound(config.sound);
}
let data = {};
if (notification.data) {
this.notificationData = notification.data;
data = notification.data;
}
if (!data.modalText && config.data.modalText) {
data.modalText = config.data.modalText(this.$t);
}
if (!data.message && config.data.message) {
data.message = config.data.message(this.$t);
}
this.notificationData = data;
if (forceToModal) {
this.$root.$emit('bv::show::modal', config.modalId);
} else {
this.text(config.label(this.$t), () => {
this.notificationData = data;
this.$root.$emit('bv::show::modal', config.modalId);
}, false);
}
@@ -619,6 +666,11 @@ export default {
case 'ACHIEVEMENT_BACK_TO_BASICS':
case 'ACHIEVEMENT_DUST_DEVIL':
case 'ACHIEVEMENT_ARID_AUTHORITY':
case 'ACHIEVEMENT_PARTY_UP':
case 'ACHIEVEMENT_PARTY_ON':
case 'ACHIEVEMENT_BEAST_MASTER':
case 'ACHIEVEMENT_MOUNT_MASTER':
case 'ACHIEVEMENT_TRIAD_BINGO':
case 'GENERIC_ACHIEVEMENT':
this.showNotificationWithModal(notification);
break;

View File

@@ -178,7 +178,6 @@
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.header-wrap {
padding: 0.5em;

View File

@@ -22,9 +22,11 @@
"achievementDustDevil": "Dust Devil",
"achievementDustDevilText": "Has collected all Desert Pets.",
"achievementDustDevilModalText": "You collected all the Desert Pets!",
"achievementPartyUp": "You teamed up with a party member!",
"achievementAridAuthority": "Arid Authority",
"achievementAridAuthorityText": "Has tamed all Desert Mounts.",
"achievementAridAuthorityModalText": "You tamed all the Desert Mounts!",
"achievementKickstarter2019": "Pin Kickstarter Backer",
"achievementKickstarter2019Text": "Backed the 2019 Pin Kickstarter Project"
"achievementKickstarter2019Text": "Backed the 2019 Pin Kickstarter Project",
"achievementPartyOn": "Your party grew to 4 members!"
}

View File

@@ -205,6 +205,10 @@
"goToSettings": "Go to Settings",
"usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!",
"usernameNotVerified": "Please confirm your username.",
"changeUsernameDisclaimer": "We will be transitioning login names to unique, public usernames soon. This username will be used for invitations, @mentions in chat, and messaging.",
"verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!"
"changeUsernameDisclaimer": "This username will be used for invitations, @mentions in chat, and messaging.",
"verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!",
"mentioning": "Mentioning",
"suggestMyUsername": "Suggest my username",
"everywhere": "Everywhere",
"onlyPrivateSpaces": "Only in private spaces"
}

View File

@@ -19,6 +19,7 @@ import guildsAllowingBannedWords from '../../libs/guildsAllowingBannedWords';
import { getMatchesByWordArray } from '../../libs/stringUtils';
import bannedSlurs from '../../libs/bannedSlurs';
import apiError from '../../libs/apiError';
import { highlightMentions } from '../../libs/highlightMentions';
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map(email => ({ email, canSend: true }));
@@ -89,7 +90,6 @@ function getBannedWordsFromText (message) {
}
const mentionRegex = new RegExp('\\B@[-\\w]+', 'g');
/**
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
* @apiName PostChat
@@ -187,6 +187,7 @@ api.postChat = {
throw new NotAuthorized(res.t('messageGroupChatSpam'));
}
const [message, mentions, mentionedMembers] = await highlightMentions(req.body.message);
let client = req.headers['x-client'] || '3rd Party';
if (client) {
client = client.replace('habitica-', '');
@@ -195,7 +196,6 @@ api.postChat = {
let flagCount = 0;
if (group.privacy === 'public' && user.flags.chatShadowMuted) {
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
const { message } = req.body;
// Email the mods
const authorEmail = getUserInfo(user, ['email']).email;
@@ -228,7 +228,14 @@ api.postChat = {
}
const newChatMessage = group.sendChat({
message: req.body.message, user, flagCount, metaData: null, client, translate: res.t,
message: req.body.message,
user,
flagCount,
metaData: null,
client,
translate: res.t,
mentions,
mentionedMembers,
});
const toSave = [newChatMessage.save()];
@@ -237,6 +244,7 @@ api.postChat = {
toSave.push(user.save());
}
await Promise.all(toSave);
const analyticsObject = {
@@ -248,7 +256,6 @@ api.postChat = {
headers: req.headers,
};
const mentions = req.body.message.match(mentionRegex);
if (mentions) {
analyticsObject.mentionsCount = mentions.length;
} else {

View File

@@ -630,16 +630,47 @@ api.joinGroup = {
if (group.type === 'party' && inviter) {
if (group.memberCount > 1) {
promises.push(User.update({
$or: [{ 'party._id': group._id }, { _id: user._id }],
'achievements.partyUp': { $ne: true },
}, { $set: { 'achievements.partyUp': true } }, { multi: true }).exec());
promises.push(User.update(
{
$or: [{ 'party._id': group._id }, { _id: user._id }],
'achievements.partyUp': { $ne: true },
},
{
$set: { 'achievements.partyUp': true },
$push: { notifications: { type: 'ACHIEVEMENT_PARTY_UP' } },
},
{ multi: true },
).exec());
if (inviter) {
if (inviter.achievements.partyUp !== true) {
// Since the notification list of the inviter is already
// updated in this save we need to add the notification here
inviter.addNotification('ACHIEVEMENT_PARTY_UP');
}
}
}
if (group.memberCount > 3) {
promises.push(User.update({
$or: [{ 'party._id': group._id }, { _id: user._id }],
'achievements.partyOn': { $ne: true },
}, { $set: { 'achievements.partyOn': true } }, { multi: true }).exec());
promises.push(User.update(
{
$or: [{ 'party._id': group._id }, { _id: user._id }],
'achievements.partyOn': { $ne: true },
},
{
$set: { 'achievements.partyOn': true },
$push: { notifications: { type: 'ACHIEVEMENT_PARTY_ON' } },
},
{ multi: true },
).exec());
if (inviter) {
if (inviter.achievements.partyOn !== true) {
// Since the notification list of the inviter is already
// updated in this save we need to add the notification here
inviter.addNotification('ACHIEVEMENT_PARTY_ON');
}
}
}
}

View File

@@ -21,6 +21,7 @@ import {
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import common from '../../../common';
import { sentMessage } from '../../libs/inbox';
import { highlightMentions } from '../../libs/highlightMentions';
const { achievements } = common;
@@ -676,7 +677,7 @@ api.sendPrivateMessage = {
if (validationErrors) throw validationErrors;
const sender = res.locals.user;
const { message } = req.body;
const message = (await highlightMentions(req.body.message))[0];
const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));

View File

@@ -17,15 +17,19 @@ export async function getAuthorEmailFromMessage (message) {
return 'Author Account Deleted';
}
export async function sendChatPushNotifications (user, group, message, translate) {
export async function sendChatPushNotifications (user, group, message, mentions, translate) {
const members = await User.find({
'party._id': group._id,
_id: { $ne: user._id },
})
.select('preferences.pushNotifications preferences.language profile.name pushDevices')
.select('preferences.pushNotifications preferences.language profile.name pushDevices auth.local.username')
.exec();
members.forEach(member => {
if (member.preferences.pushNotifications.partyActivity !== false) {
if (mentions && mentions.includes(`@${member.auth.local.username}`) && member.preferences.pushNotifications.mentionParty !== false) {
return;
}
sendPushNotification(
member,
{

View File

@@ -0,0 +1,23 @@
import { model as User } from '../models/user';
const mentionRegex = new RegExp('\\B@[-\\w]+', 'g');
export async function highlightMentions (text) { // eslint-disable-line import/prefer-default-export
const mentions = text.match(mentionRegex);
let members = [];
if (mentions !== null && mentions.length <= 5) {
const usernames = mentions.map(mention => mention.substr(1));
members = await User
.find({ 'auth.local.username': { $in: usernames }, 'flags.verifiedUsername': true })
.select(['auth.local.username', '_id', 'preferences.pushNotifications', 'pushDevices'])
.lean()
.exec();
members.forEach(member => {
const { username } = member.auth.local;
// eslint-disable-next-line no-param-reassign
text = text.replace(new RegExp(`@${username}(?![\\-\\w])`, 'g'), `[@${username}](/profile/${member._id})`);
});
}
return [text, mentions, members];
}

View File

@@ -541,9 +541,12 @@ schema.methods.getMemberCount = async function getMemberCount () {
schema.methods.sendChat = function sendChat (options = {}) {
const {
message, user, metaData, client, flagCount = 0, info = {}, translate,
message, user, metaData,
client, flagCount = 0, info = {},
translate, mentions, mentionedMembers,
} = options;
const newMessage = messageDefaults(message, user, client, flagCount, info);
let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage);
newChatMessage.groupId = this._id;
@@ -609,9 +612,31 @@ schema.methods.sendChat = function sendChat (options = {}) {
});
if (this.type === 'party' && user) {
sendChatPushNotifications(user, this, newChatMessage, translate);
sendChatPushNotifications(user, this, newChatMessage, mentions, translate);
}
if (mentionedMembers) {
mentionedMembers.forEach(member => {
if (member._id === user._id) return;
const pushNotifPrefs = member.preferences.pushNotifications;
if (this.type === 'party') {
if (pushNotifPrefs.mentionParty !== true || !this.isMember(member)) {
return;
}
} else if (this.isMember(member)) {
if (pushNotifPrefs.mentionJoinedGuild !== true) {
return;
}
} else {
if (this.privacy !== 'public') {
return;
}
if (pushNotifPrefs.mentionUnjoinedGuild !== true) {
return;
}
}
sendPushNotification(member, { identifier: 'chatMention', title: `${user.profile.name} mentioned you in ${this.name}`, message });
});
}
return newChatMessage;
};

View File

@@ -190,23 +190,35 @@ schema.pre('save', true, function preSaveUser (next, done) {
// Determines if Beast Master should be awarded
const beastMasterProgress = common.count.beastMasterProgress(this.items.pets);
if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) {
if (
(beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0)
&& this.achievements.beastMaster !== true
) {
this.achievements.beastMaster = true;
this.addNotification('ACHIEVEMENT_BEAST_MASTER');
}
// Determines if Mount Master should be awarded
const mountMasterProgress = common.count.mountMasterProgress(this.items.mounts);
if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) {
if (
(mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0)
&& this.achievements.mountMaster !== true
) {
this.achievements.mountMaster = true;
this.addNotification('ACHIEVEMENT_MOUNT_MASTER');
}
// Determines if Triad Bingo should be awarded
const dropPetCount = common.count.dropPetsCurrentlyOwned(this.items.pets);
const qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90;
if (qualifiesForTriad || this.achievements.triadBingoCount > 0) {
if (
(qualifiesForTriad || this.achievements.triadBingoCount > 0)
&& this.achievements.triadBingo !== true
) {
this.achievements.triadBingo = true;
this.addNotification('ACHIEVEMENT_TRIAD_BINGO');
}
// EXAMPLE CODE for allowing all existing and new players to be

View File

@@ -40,6 +40,11 @@ const NOTIFICATION_TYPES = [
'ACHIEVEMENT_MIND_OVER_MATTER',
'ACHIEVEMENT_DUST_DEVIL',
'ACHIEVEMENT_ARID_AUTHORITY',
'ACHIEVEMENT_PARTY_UP',
'ACHIEVEMENT_PARTY_ON',
'ACHIEVEMENT_BEAST_MASTER',
'ACHIEVEMENT_MOUNT_MASTER',
'ACHIEVEMENT_TRIAD_BINGO',
];
const { Schema } = mongoose;