Improve @mention handling (#10872)

* move the update username route to v3 (#10836)

* Add API Call to retrieve auto-complete options for usernames

* Create links to users profile in chat messages

* Begin adding server-side autocomplete to web client

* Add Test to opt out of username being searchable

* Fix issue with username highlighting

* Correctly update message text when using autocomplete

* remove old autocomplete component

* Improve chat input design

* rewrite mongoose migration to avoid using recursion

* fixes

* select more fields

* use lean and .update

* fix(tests): correct expects

* fix(tests): linting & more expects
Also one more tweak for invite validation responsiveness

* chore(news): Bailey

* chore(i18n): update locales

* 4.70.0

* fix(chat): less intrusive highlight and better margins

* fix(chat): more width tweakage

* feat(content): Oddballs Bundle
Also includes one more tweak to @mention text highlighting

* chore(sprites): compile

* chore(i18n): update locales

* 4.71.0

* groupChatReceived webhook fix (#10802)

* Moved sendGroupChatReceivedWebhooks to group.sendChat function.

* Added test for new functionality.

* Set width on .custom-control-label (#10840)

Set `width: 100%` on the `.custom-control-label`.

Although `overflow-wrap: break-word` is set on the parent `.checklist-item` element, it doesn't seem to take effect unless a width is set on the label.

* Very large Guild member counts overflow the badge #10753 (#10812)

* Update superagent to the latest version 🚀 (#10848)

* fix(package): update superagent to version 4.0.0

* chore(package): update lockfile package-lock.json

* fix(chat): prevent duplicate messages, closes #10823

* Fix for #10814, prevent ParallelSave errors (#10852)

* fix(group leave): prevent ParallelSave errors while leaving a group with multiple group or challenge tasks

* fix typo

* move computed-props to methods - refactor mountItem to use the states inside (#10853)

* feat(content): Frost Hatching Potions

* chore(sprites): compile

* chore(i18n): update locales

* 4.72.0

* fix(stable): remove progress number from petItem

* add two slurs - TRIGGER / CONTENT WARNING: assault, slurs, swearwords, etc

* more checks on the item.klass, also added the specialClass checks (#10859)

* feat(content): Turkey Day 2018

* chore(sprites): compile

* chore(i18n): update locales

* 4.73.0

* chore(i18n): update locales

* 4.73.1

* feat(footer): always show expanded footer (#10862)

* Fixes issue #10857 ("Tags have extra space at the bottom when they should be centered") (#10861)

* Fix for #10857 centered category tag text

* Fixes #10857 and #10856 display tag markdown.

* Attach client to chat messages (#10845)

* Attach client to chat messages

* Word

* Design tweaks

* Fix potential error

* chore(event): end Thanksgiving tweaks

* chore(i18n): update locales

* 4.73.2

* Improve chat input design

* Fix test errors

* Move tier icons import to index

* correctly name event variable

* Debounce autocomplete calls

* optimize mention highlighting

* fix failing tests

* Fix sending private messages

* Cache username autocomplete requests

* optimize autocomplete regex

* Fix lint error

* add optional parameters to limit autocompletion to specific group

* Fix non-profile urls not being usable.

* Correctly handle autocomplete for public and private guilds

* Add check to make sure users don’t search for parties/guilds they are not part of

* fix lint error

* limit autocomplete results to 5

* fix(mentioning): change default, adapt settings control to checkbox

* Improve auto completing

* improve username autocomplete

* Fix merge issue

* remove old code

* Send push notifications on mentions

* Improve handling for sending mention notifications

* Fix lint error

* Update schema.js

* Fix failing test

* Don't send push notification to users who aren't in the party

* Remove tributejs from dependencies
This commit is contained in:
Matteo Pagliazzi
2019-10-18 17:05:13 +02:00
committed by GitHub
10 changed files with 170 additions and 20 deletions

18
package-lock.json generated
View File

@@ -13877,6 +13877,14 @@
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
"integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw=="
}, },
"is-builtin-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz",
"integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=",
"requires": {
"builtin-modules": "^1.0.0"
}
},
"is-callable": { "is-callable": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
@@ -25708,6 +25716,11 @@
"punycode": "^1.4.1" "punycode": "^1.4.1"
} }
}, },
"tributejs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tributejs/-/tributejs-3.4.0.tgz",
"integrity": "sha512-BWB2YvfKpa6hZgcP9hKN5/tH3P/Guspn4r+ePgwNpftnQwMb6GVWTUgBpkMtVXkR5dwLLcP/iW87i9C1mp21zQ=="
},
"trim-leading-lines": { "trim-leading-lines": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/trim-leading-lines/-/trim-leading-lines-0.1.1.tgz", "resolved": "https://registry.npmjs.org/trim-leading-lines/-/trim-leading-lines-0.1.1.tgz",
@@ -27112,6 +27125,11 @@
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==" "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
}, },
"vue-tribute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vue-tribute/-/vue-tribute-1.0.1.tgz",
"integrity": "sha1-ThJfdoEjxUBd4izjQ4NqmaCVRz0="
},
"vuedraggable": { "vuedraggable": {
"version": "2.23.1", "version": "2.23.1",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.1.tgz", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.1.tgz",

View File

@@ -0,0 +1,64 @@
import {
highlightMentions,
} from '../../../../website/server/libs/highlightMentions';
import mongoose from 'mongoose';
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 () => {
let text = 'some chat text';
let result = await highlightMentions(text);
expect(result[0]).to.equal(text);
});
it('highlights existing users', async () => {
let text = '@user: message';
let result = await highlightMentions(text);
expect(result[0]).to.equal('[@user](/profile/111): message');
});
it('highlights special characters', async () => {
let text = '@user-dash: message @user_underscore';
let 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 () => {
let text = '@nouser message';
let result = await highlightMentions(text);
expect(result[0]).to.equal('@nouser message');
});
it('highlights multiple existing users', async () => {
let text = '@user message (@user2) @user3 @user';
let 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 () => {
let text = '@user @user2 @user3 @user4 @user5 @user6';
let result = await highlightMentions(text);
expect(result[0]).to.equal(text);
});
});

View File

@@ -9,7 +9,7 @@ div
span.mr-1(v-if="msg.username") span.mr-1(v-if="msg.username")
span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }}  span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }} 
span(v-if="msg.client && user.contributor.level >= 4") ({{ msg.client }}) span(v-if="msg.client && user.contributor.level >= 4") ({{ msg.client }})
.text(v-html='atHighlight(parseMarkdown(msg.text))') .text(v-html='atHighlight(parseMarkdown(msg.text))', ref='markdownContainer')
.reported(v-if="isMessageReported && (inbox === true)") .reported(v-if="isMessageReported && (inbox === true)")
span(v-once) {{ $t('reportedMessage')}} span(v-once) {{ $t('reportedMessage')}}
br br
@@ -47,7 +47,6 @@ div
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss';
.mentioned-icon { .mentioned-icon {
width: 16px; width: 16px;
@@ -217,6 +216,21 @@ export default {
return 'Message hidden (shadow-muted)'; return 'Message hidden (shadow-muted)';
}, },
}, },
mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
for (let i = 0; i < links.length; i++) {
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);
},
methods: { methods: {
async like () { async like () {
let message = cloneDeep(this.msg); let message = cloneDeep(this.msg);
@@ -279,10 +293,5 @@ export default {
return habiticaMarkdown.render(String(text)); return habiticaMarkdown.render(String(text));
}, },
}, },
mounted () {
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);
},
}; };
</script> </script>

View File

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

View File

@@ -205,6 +205,10 @@
"goToSettings": "Go to Settings", "goToSettings": "Go to Settings",
"usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!", "usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!",
"usernameNotVerified": "Please confirm your username.", "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.", "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!" "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 { getMatchesByWordArray } from '../../libs/stringUtils';
import bannedSlurs from '../../libs/bannedSlurs'; import bannedSlurs from '../../libs/bannedSlurs';
import apiError from '../../libs/apiError'; import apiError from '../../libs/apiError';
import {highlightMentions} from '../../libs/highlightMentions';
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
return { email, canSend: true }; return { email, canSend: true };
@@ -90,7 +91,6 @@ function getBannedWordsFromText (message) {
} }
const mentionRegex = new RegExp('\\B@[-\\w]+', 'g');
/** /**
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group * @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
* @apiName PostChat * @apiName PostChat
@@ -183,6 +183,7 @@ api.postChat = {
throw new NotAuthorized(res.t('messageGroupChatSpam')); throw new NotAuthorized(res.t('messageGroupChatSpam'));
} }
const [message, mentions, mentionedMembers] = await highlightMentions(req.body.message);
let client = req.headers['x-client'] || '3rd Party'; let client = req.headers['x-client'] || '3rd Party';
if (client) { if (client) {
client = client.replace('habitica-', ''); client = client.replace('habitica-', '');
@@ -191,7 +192,6 @@ api.postChat = {
let flagCount = 0; let flagCount = 0;
if (group.privacy === 'public' && user.flags.chatShadowMuted) { if (group.privacy === 'public' && user.flags.chatShadowMuted) {
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE; flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
let message = req.body.message;
// Email the mods // Email the mods
let authorEmail = getUserInfo(user, ['email']).email; let authorEmail = getUserInfo(user, ['email']).email;
@@ -223,7 +223,14 @@ api.postChat = {
}); });
} }
const newChatMessage = group.sendChat({message: req.body.message, user, flagCount, metaData: null, client, translate: res.t}); const newChatMessage = group.sendChat({message: req.body.message,
user,
flagCount,
metaData: null,
client,
translate: res.t,
mentions,
mentionedMembers});
let toSave = [newChatMessage.save()]; let toSave = [newChatMessage.save()];
if (group.type === 'party') { if (group.type === 'party') {
@@ -231,6 +238,7 @@ api.postChat = {
toSave.push(user.save()); toSave.push(user.save());
} }
await Promise.all(toSave); await Promise.all(toSave);
let analyticsObject = { let analyticsObject = {
@@ -242,7 +250,6 @@ api.postChat = {
headers: req.headers, headers: req.headers,
}; };
const mentions = req.body.message.match(mentionRegex);
if (mentions) { if (mentions) {
analyticsObject.mentionsCount = mentions.length; analyticsObject.mentionsCount = mentions.length;
} else { } else {

View File

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

View File

@@ -18,15 +18,18 @@ export async function getAuthorEmailFromMessage (message) {
} }
} }
export async function sendChatPushNotifications (user, group, message, translate) { export async function sendChatPushNotifications (user, group, message, mentions, translate) {
let members = await User.find({ let members = await User.find({
'party._id': group._id, 'party._id': group._id,
_id: {$ne: user._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(); .exec();
members.forEach(member => { members.forEach(member => {
if (member.preferences.pushNotifications.partyActivity !== false) { if (member.preferences.pushNotifications.partyActivity !== false) {
if (mentions && mentions.includes(`@${member.auth.local.username}`) && member.preferences.pushNotifications.mentionParty !== false) {
return;
}
sendPushNotification( sendPushNotification(
member, 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) {
const mentions = text.match(mentionRegex);
let members = [];
if (mentions !== null && mentions.length <= 5) {
const usernames = mentions.map((mention) => {
return 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.username;
text = text.replace(new RegExp(`@${username}(?![\\-\\w])`, 'g'), `[@${username}](/profile/${member._id})`);
});
}
return [text, mentions, members];
}

View File

@@ -513,7 +513,7 @@ schema.methods.getMemberCount = async function getMemberCount () {
}; };
schema.methods.sendChat = function sendChat (options = {}) { schema.methods.sendChat = function sendChat (options = {}) {
const {message, user, metaData, client, flagCount = 0, info = {}, translate} = options; const {message, user, metaData, client, flagCount = 0, info = {}, translate, mentions, mentionedMembers} = options;
let newMessage = messageDefaults(message, user, client, flagCount, info); let newMessage = messageDefaults(message, user, client, flagCount, info);
let newChatMessage = new Chat(); let newChatMessage = new Chat();
newChatMessage = Object.assign(newChatMessage, newMessage); newChatMessage = Object.assign(newChatMessage, newMessage);
@@ -576,9 +576,31 @@ schema.methods.sendChat = function sendChat (options = {}) {
}); });
if (this.type === 'party' && user) { 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; return newChatMessage;
}; };