From c80de385724d635cb199c1705818367663501036 Mon Sep 17 00:00:00 2001 From: Alys Date: Wed, 31 Jul 2019 03:09:42 +1000 Subject: [PATCH] add shadow-muting chat hiding feature (automatic flagging of public posts from shadow-muted players) - fixes 10851 (#11239) * move existing tests for chatRevoked users to 'mute user' describe block * give consistent names to chatRevoked tests and use const not let * improve methods for restoring chat permissions to test users * add tests for shadow-muting and define constants for flag-related numbers * update user profile URLs and reverse private/public 'if' statements * implement shadow muting in the API and schemas * add interface for mods to turn shadow muting on/off for a user - checkbox in the Hall - icon in the user's profile * mark chat posts as being shadow muted (marking is visible to mods only) * convert Admin Tools in profile from icons to text; make crown icon a toggle * move logic for displaying flag count to a computed property * prevent chat notifications for shadow-muted posts --- .../api/v3/integration/chat/POST-chat.test.js | 248 +++++++++++++----- .../hall/PUT-hall_heores_heroId.test.js | 12 +- website/client/components/chat/chatCard.vue | 14 +- website/client/components/hall/heroes.vue | 6 + .../client/components/userMenu/profile.vue | 73 ++++-- website/common/script/constants.js | 5 + website/common/script/index.js | 6 + website/server/controllers/api-v3/chat.js | 49 +++- website/server/controllers/api-v3/hall.js | 8 +- website/server/libs/slack.js | 44 ++++ website/server/models/group.js | 17 +- website/server/models/message.js | 4 +- website/server/models/user/schema.js | 1 + 13 files changed, 375 insertions(+), 112 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index aa6b8c8806..73ac2d3bf9 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -12,6 +12,7 @@ import { SPAM_MIN_EXEMPT_CONTRIB_LEVEL, TAVERN_ID, } from '../../../../../website/server/models/group'; +import { CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../../../website/common/script/constants'; import { v4 as generateUUID } from 'uuid'; import { getMatchesByWordArray } from '../../../../../website/server/libs/stringUtils'; import bannedWords from '../../../../../website/server/libs/bannedWords'; @@ -81,6 +82,10 @@ describe('POST /chat', () => { }); describe('mute user', () => { + afterEach(() => { + member.update({'flags.chatRevoked': false}); + }); + it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => { const userWithChatRevoked = await member.update({'flags.chatRevoked': true}); await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ @@ -89,6 +94,129 @@ describe('POST /chat', () => { message: t('chatPrivilegesRevoked'), }); }); + + it('does not error when chat privileges are revoked when sending a message to a private guild', async () => { + const { group, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Private Guild', + type: 'guild', + privacy: 'private', + }, + members: 1, + }); + + const privateGuildMemberWithChatsRevoked = members[0]; + await privateGuildMemberWithChatsRevoked.update({'flags.chatRevoked': true}); + + const message = await privateGuildMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage}); + + expect(message.message.id).to.exist; + }); + + it('does not error when chat privileges are revoked when sending a message to a party', async () => { + const { group, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Party', + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + const privatePartyMemberWithChatsRevoked = members[0]; + await privatePartyMemberWithChatsRevoked.update({'flags.chatRevoked': true}); + + const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage}); + + expect(message.message.id).to.exist; + }); + }); + + describe('shadow-mute user', () => { + beforeEach(() => { + sandbox.spy(email, 'sendTxn'); + sandbox.stub(IncomingWebhook.prototype, 'send'); + }); + + afterEach(() => { + sandbox.restore(); + member.update({'flags.chatShadowMuted': false}); + }); + + it('creates a chat with flagCount already set and notifies mods when sending a message to a public guild', async () => { + const userWithChatShadowMuted = await member.update({'flags.chatShadowMuted': true}); + const message = await userWithChatShadowMuted.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + expect(message.message.id).to.exist; + expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE); + + // Email sent to mods + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; + expect(email.sendTxn.args[0][1]).to.eql('shadow-muted-post-report-to-mods'); + + // Slack message to mods + expect(IncomingWebhook.prototype.send).to.be.calledOnce; + /* eslint-disable camelcase */ + expect(IncomingWebhook.prototype.send).to.be.calledWith({ + text: `@${member.auth.local.username} / ${member.profile.name} posted while shadow-muted`, + attachments: [{ + fallback: 'Shadow-Muted Message', + color: 'danger', + author_name: `@${member.auth.local.username} ${member.profile.name} (${member.auth.local.email}; ${member._id})`, + title: 'Shadow-Muted Post in Test Guild', + title_link: `${BASE_URL}/groups/guild/${groupWithChat.id}`, + text: testMessage, + mrkdwn_in: [ + 'text', + ], + }], + }); + /* eslint-enable camelcase */ + }); + + it('creates a chat with zero flagCount when sending a message to a private guild', async () => { + const { group, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Private Guild', + type: 'guild', + privacy: 'private', + }, + members: 1, + }); + + const userWithChatShadowMuted = members[0]; + await userWithChatShadowMuted.update({'flags.chatShadowMuted': true}); + + const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage}); + + expect(message.message.id).to.exist; + expect(message.message.flagCount).to.eql(0); + }); + + it('creates a chat with zero flagCount when sending a message to a party', async () => { + const { group, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Party', + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + const userWithChatShadowMuted = members[0]; + await userWithChatShadowMuted.update({'flags.chatShadowMuted': true}); + + const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage}); + + expect(message.message.id).to.exist; + expect(message.message.flagCount).to.eql(0); + }); + + it('creates a chat with zero flagCount when non-shadow-muted user sends a message to a public guild', async () => { + const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + expect(message.message.id).to.exist; + expect(message.message.flagCount).to.eql(0); + }); }); context('banned word', () => { @@ -235,6 +363,7 @@ describe('POST /chat', () => { afterEach(() => { sandbox.restore(); + user.update({'flags.chatRevoked': false}); }); it('errors and revokes privileges when chat message contains a banned slur', async () => { @@ -274,11 +403,6 @@ describe('POST /chat', () => { error: 'NotAuthorized', message: t('chatPrivilegesRevoked'), }); - - // @TODO: The next test should not depend on this. We should reset the user test in a beforeEach - // Restore chat privileges to continue testing - user.flags.chatRevoked = false; - await user.update({'flags.chatRevoked': false}); }); it('does not allow slurs in private groups', async () => { @@ -327,10 +451,6 @@ describe('POST /chat', () => { error: 'NotAuthorized', message: t('chatPrivilegesRevoked'), }); - - // Restore chat privileges to continue testing - members[0].flags.chatRevoked = false; - await members[0].update({'flags.chatRevoked': false}); }); it('errors when slur is typed in mixed case', async () => { @@ -345,42 +465,6 @@ describe('POST /chat', () => { }); }); - it('does not error when sending a message to a private guild with a user with revoked chat', async () => { - let { group, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Private Guild', - type: 'guild', - privacy: 'private', - }, - members: 1, - }); - - let privateGuildMemberWithChatsRevoked = members[0]; - await privateGuildMemberWithChatsRevoked.update({'flags.chatRevoked': true}); - - let message = await privateGuildMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage}); - - expect(message.message.id).to.exist; - }); - - it('does not error when sending a message to a party with a user with revoked chat', async () => { - let { group, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Party', - type: 'party', - privacy: 'private', - }, - members: 1, - }); - - let privatePartyMemberWithChatsRevoked = members[0]; - await privatePartyMemberWithChatsRevoked.update({'flags.chatRevoked': true}); - - let message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage}); - - expect(message.message.id).to.exist; - }); - it('creates a chat', async () => { const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`); @@ -486,35 +570,55 @@ describe('POST /chat', () => { }); }); - it('notifies other users of new messages for a guild', async () => { - let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - let memberWithNotification = await member.get('/user'); - - expect(message.message.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 () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - name: 'Test Party', - type: 'party', - privacy: 'private', - }, - members: 1, + context('chat notifications', () => { + beforeEach(() => { + member.update({newMessages: {}, notifications: []}); }); - let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); - let memberWithNotification = await members[0].get('/user'); + it('notifies other users of new messages for a guild', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); + let memberWithNotification = await member.get('/user'); - expect(message.message.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; + expect(message.message.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 () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage }); + let memberWithNotification = await members[0].get('/user'); + + expect(message.message.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; + }); + + it('does not notify other users of a new message that is already hidden from shadow-muting', async () => { + await user.update({'flags.chatShadowMuted': true}); + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); + let memberWithNotification = await member.get('/user'); + + await user.update({'flags.chatShadowMuted': false}); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.not.exist; + expect(memberWithNotification.notifications.find(n => { + return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupWithChat._id; + })).to.not.exist; + }); }); context('Spam prevention', () => { @@ -533,7 +637,7 @@ describe('POST /chat', () => { }); it('contributor should not receive spam alert', async () => { - let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, 'flags.chatRevoked': false}); + let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL}); // Post 1 more message than the spam limit to ensure they do not reach the limit for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) { diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js index 53a138c8bb..36e8e27842 100644 --- a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -105,16 +105,22 @@ describe('PUT /heroes/:heroId', () => { it('updates chatRevoked flag', async () => { let hero = await generateUser(); - await user.put(`/hall/heroes/${hero._id}`, { flags: {chatRevoked: true}, }); - await hero.sync(); - expect(hero.flags.chatRevoked).to.eql(true); }); + it('updates chatShadowMuted flag', async () => { + let hero = await generateUser(); + await user.put(`/hall/heroes/${hero._id}`, { + flags: {chatShadowMuted: true}, + }); + await hero.sync(); + expect(hero.flags.chatShadowMuted).to.eql(true); + }); + it('updates contributor level', async () => { let hero = await generateUser({ contributor: {level: 5}, diff --git a/website/client/components/chat/chatCard.vue b/website/client/components/chat/chatCard.vue index 7b3c476e71..4327accc92 100644 --- a/website/client/components/chat/chatCard.vue +++ b/website/client/components/chat/chatCard.vue @@ -1,8 +1,7 @@