mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 14:17:22 +01:00
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
This commit is contained in:
@@ -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,8 +570,13 @@ describe('POST /chat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
context('chat notifications', () => {
|
||||
beforeEach(() => {
|
||||
member.update({newMessages: {}, notifications: []});
|
||||
});
|
||||
|
||||
it('notifies other users of new messages for a guild', async () => {
|
||||
let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
|
||||
let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
|
||||
let memberWithNotification = await member.get('/user');
|
||||
|
||||
expect(message.message.id).to.exist;
|
||||
@@ -507,7 +596,7 @@ describe('POST /chat', () => {
|
||||
members: 1,
|
||||
});
|
||||
|
||||
let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage});
|
||||
let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage });
|
||||
let memberWithNotification = await members[0].get('/user');
|
||||
|
||||
expect(message.message.id).to.exist;
|
||||
@@ -517,6 +606,21 @@ describe('POST /chat', () => {
|
||||
})).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', () => {
|
||||
it('Returns an error when the user has been posting too many messages', async () => {
|
||||
// Post as many messages are needed to reach the spam limit
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
.mentioned-icon(v-if='isUserMentioned')
|
||||
.message-hidden(v-if='!inbox && msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden
|
||||
.message-hidden(v-if='!inbox && msg.flagCount > 1 && user.contributor.admin') Message hidden
|
||||
.message-hidden(v-if='!inbox && user.contributor.admin && msg.flagCount') {{flagCountDescription}}
|
||||
.card-body
|
||||
user-link(:userId="msg.uuid", :name="msg.user", :backer="msg.backer", :contributor="msg.contributor")
|
||||
p.time
|
||||
@@ -137,7 +136,8 @@ import copyIcon from 'assets/svg/copy.svg';
|
||||
import likeIcon from 'assets/svg/like.svg';
|
||||
import likedIcon from 'assets/svg/liked.svg';
|
||||
import reportIcon from 'assets/svg/report.svg';
|
||||
import {highlightUsers} from '../../libs/highlightUsers';
|
||||
import { highlightUsers } from '../../libs/highlightUsers';
|
||||
import { CHAT_FLAG_LIMIT_FOR_HIDING, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../common/script/constants';
|
||||
|
||||
export default {
|
||||
components: {userLink},
|
||||
@@ -210,6 +210,12 @@ export default {
|
||||
isMessageReported () {
|
||||
return this.msg.flags && this.msg.flags[this.user.id] || this.reported;
|
||||
},
|
||||
flagCountDescription () {
|
||||
if (!this.msg.flagCount) return '';
|
||||
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) return 'Message flagged once, not hidden';
|
||||
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
|
||||
return 'Message hidden (shadow-muted)';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async like () {
|
||||
@@ -274,6 +280,8 @@ export default {
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
h4.expand-toggle(:class="{'open': expandAuth}", @click="expandAuth = !expandAuth") Auth
|
||||
div(v-if="expandAuth")
|
||||
pre {{hero.auth}}
|
||||
.form-group
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', v-if='hero.flags', v-model='hero.flags.chatShadowMuted')
|
||||
strong Chat Shadow Muting On
|
||||
.form-group
|
||||
.checkbox
|
||||
label
|
||||
@@ -180,6 +185,7 @@ export default {
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {
|
||||
chatRevoked: false,
|
||||
chatShadowMuted: false,
|
||||
};
|
||||
}
|
||||
this.expandItems = false;
|
||||
|
||||
@@ -12,22 +12,29 @@
|
||||
button.btn.btn-secondary.positive-icon(v-if='user._id !== this.userLoggedIn._id && userLoggedIn.inbox.blocks.indexOf(user._id) !== -1',
|
||||
@click="unblockUser()", v-b-tooltip.hover.right="$t('unblock')")
|
||||
.svg-icon.positive-icon(v-html="icons.positive")
|
||||
button.btn.btn-secondary.positive-icon(v-if='this.userLoggedIn.contributor.admin && !adminToolsLoaded',
|
||||
@click="loadAdminTools()", v-b-tooltip.hover.right="'Admin - Load Tools'")
|
||||
button.btn.btn-secondary.positive-icon(v-if='this.userLoggedIn.contributor.admin',
|
||||
@click="toggleAdminTools()", v-b-tooltip.hover.right="'Admin - Toggle Tools'")
|
||||
.svg-icon.positive-icon(v-html="icons.staff")
|
||||
span(v-if='this.userLoggedIn.contributor.admin && adminToolsLoaded')
|
||||
button.btn.btn-secondary.positive-icon(v-if='!hero.flags || (hero.flags && !hero.flags.chatRevoked)',
|
||||
@click="adminRevokeChat()", v-b-tooltip.hover.bottom="'Admin - Revoke Chat Privileges'")
|
||||
.svg-icon.positive-icon(v-html="icons.megaphone")
|
||||
button.btn.btn-secondary.positive-icon(v-if='hero.flags && hero.flags.chatRevoked',
|
||||
@click="adminReinstateChat()", v-b-tooltip.hover.bottom="'Admin - Reinstate Chat Privileges'")
|
||||
.svg-icon.positive-icon(v-html="icons.challenge")
|
||||
button.btn.btn-secondary.positive-icon(v-if='!hero.auth.blocked',
|
||||
@click="adminBlockUser()", v-b-tooltip.hover.right="'Admin - Ban User'")
|
||||
.svg-icon.positive-icon(v-html="icons.lock")
|
||||
button.btn.btn-secondary.positive-icon(v-if='hero.auth.blocked',
|
||||
@click="adminUnblockUser()", v-b-tooltip.hover.right="'Admin - Unblock User'")
|
||||
.svg-icon.positive-icon(v-html="icons.member")
|
||||
.row.admin-profile-actions(v-if='this.userLoggedIn.contributor.admin && adminToolsLoaded')
|
||||
.col-12.text-right
|
||||
span.admin-action(v-if='!hero.flags || (hero.flags && !hero.flags.chatShadowMuted)',
|
||||
@click="adminTurnOnShadowMuting()", v-b-tooltip.hover.bottom="'Turn on Shadow Muting'")
|
||||
| shadow-mute
|
||||
span.admin-action(v-if='hero.flags && hero.flags.chatShadowMuted',
|
||||
@click="adminTurnOffShadowMuting()", v-b-tooltip.hover.bottom="'Turn off Shadow Muting'")
|
||||
| un-shadow-mute
|
||||
span.admin-action(v-if='!hero.flags || (hero.flags && !hero.flags.chatRevoked)',
|
||||
@click="adminRevokeChat()", v-b-tooltip.hover.bottom="'Revoke Chat Privileges'")
|
||||
| mute
|
||||
span.admin-action(v-if='hero.flags && hero.flags.chatRevoked',
|
||||
@click="adminReinstateChat()", v-b-tooltip.hover.bottom="'Reinstate Chat Privileges'")
|
||||
| un-mute
|
||||
span.admin-action(v-if='!hero.auth.blocked',
|
||||
@click="adminBlockUser()", v-b-tooltip.hover.bottom="'Ban User'")
|
||||
| ban
|
||||
span.admin-action(v-if='hero.auth.blocked',
|
||||
@click="adminUnblockUser()", v-b-tooltip.hover.bottom="'Un-Ban User'")
|
||||
| un-ban
|
||||
.row
|
||||
.col-12
|
||||
member-details(:member="user")
|
||||
@@ -184,6 +191,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-profile-actions {
|
||||
margin-bottom: 3em;
|
||||
|
||||
.admin-action {
|
||||
color: blue;
|
||||
cursor: pointer;
|
||||
padding: 0 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
float: right;
|
||||
margin-right: 1em;
|
||||
@@ -586,6 +603,22 @@ export default {
|
||||
openSendGemsModal () {
|
||||
this.$root.$emit('habitica::send-gems', this.user);
|
||||
},
|
||||
adminTurnOnShadowMuting () {
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {};
|
||||
}
|
||||
this.hero.flags.chatShadowMuted = true;
|
||||
|
||||
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
|
||||
},
|
||||
adminTurnOffShadowMuting () {
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {};
|
||||
}
|
||||
this.hero.flags.chatShadowMuted = false;
|
||||
|
||||
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
|
||||
},
|
||||
adminRevokeChat () {
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {};
|
||||
@@ -612,9 +645,13 @@ export default {
|
||||
|
||||
this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
|
||||
},
|
||||
async loadAdminTools () {
|
||||
async toggleAdminTools () {
|
||||
if (this.adminToolsLoaded) {
|
||||
this.adminToolsLoaded = false;
|
||||
} else {
|
||||
this.hero = await this.$store.dispatch('hall:getHero', { uuid: this.user._id });
|
||||
this.adminToolsLoaded = true;
|
||||
}
|
||||
},
|
||||
showAllocation () {
|
||||
return this.user._id === this.userLoggedIn._id && this.hasClass;
|
||||
|
||||
@@ -10,6 +10,11 @@ export const MAX_SUMMARY_SIZE_FOR_GUILDS = 250;
|
||||
export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250;
|
||||
export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
|
||||
|
||||
export const CHAT_FLAG_LIMIT_FOR_HIDING = 2; // hide posts that have this many flags
|
||||
export const CHAT_FLAG_FROM_MOD = 5; // a flag from a moderator counts as this many flags
|
||||
export const CHAT_FLAG_FROM_SHADOW_MUTE = 10; // a shadow-muted user's post starts with this many flags
|
||||
// @TODO use those constants to replace hard-coded numbers
|
||||
|
||||
export const SUPPORTED_SOCIAL_NETWORKS = [
|
||||
{key: 'facebook', name: 'Facebook'},
|
||||
{key: 'google', name: 'Google'},
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
SUPPORTED_SOCIAL_NETWORKS,
|
||||
GUILDS_PER_PAGE,
|
||||
PARTY_LIMIT_MEMBERS,
|
||||
CHAT_FLAG_LIMIT_FOR_HIDING,
|
||||
CHAT_FLAG_FROM_MOD,
|
||||
CHAT_FLAG_FROM_SHADOW_MUTE,
|
||||
} from './constants';
|
||||
|
||||
api.constants = {
|
||||
@@ -40,6 +43,9 @@ api.constants = {
|
||||
SUPPORTED_SOCIAL_NETWORKS,
|
||||
GUILDS_PER_PAGE,
|
||||
PARTY_LIMIT_MEMBERS,
|
||||
CHAT_FLAG_LIMIT_FOR_HIDING,
|
||||
CHAT_FLAG_FROM_MOD,
|
||||
CHAT_FLAG_FROM_SHADOW_MUTE,
|
||||
};
|
||||
// TODO Move these under api.constants
|
||||
api.maxLevel = MAX_LEVEL;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/auth';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import { chatModel as Chat } from '../../models/message';
|
||||
import common from '../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
@@ -139,7 +140,7 @@ api.postChat = {
|
||||
{name: 'AUTHOR_USERNAME', content: user.profile.name},
|
||||
{name: 'AUTHOR_UUID', content: user._id},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
@@ -162,12 +163,12 @@ api.postChat = {
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
if (group.privacy !== 'private' && user.flags.chatRevoked) {
|
||||
if (group.privacy === 'public' && user.flags.chatRevoked) {
|
||||
throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
||||
}
|
||||
|
||||
// prevent banned words being posted, except in private guilds/parties and in certain public guilds with specific topics
|
||||
if (group.privacy !== 'private' && !guildsAllowingBannedWords[group._id]) {
|
||||
if (group.privacy === 'public' && !guildsAllowingBannedWords[group._id]) {
|
||||
let matchedBadWords = getBannedWordsFromText(req.body.message);
|
||||
if (matchedBadWords.length > 0) {
|
||||
throw new BadRequest(res.t('bannedWordUsed', {swearWordsUsed: matchedBadWords.join(', ')}));
|
||||
@@ -186,7 +187,43 @@ api.postChat = {
|
||||
if (client) {
|
||||
client = client.replace('habitica-', '');
|
||||
}
|
||||
const newChatMessage = group.sendChat({message: req.body.message, user, metaData: null, client});
|
||||
|
||||
let flagCount = 0;
|
||||
if (group.privacy === 'public' && user.flags.chatShadowMuted) {
|
||||
flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE;
|
||||
let message = req.body.message;
|
||||
|
||||
// Email the mods
|
||||
let authorEmail = getUserInfo(user, ['email']).email;
|
||||
let groupUrl = getGroupUrl(group);
|
||||
|
||||
let report = [
|
||||
{name: 'MESSAGE_TIME', content: (new Date()).toString()},
|
||||
{name: 'MESSAGE_TEXT', content: message},
|
||||
|
||||
{name: 'AUTHOR_USERNAME', content: user.profile.name},
|
||||
{name: 'AUTHOR_UUID', content: user._id},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
{name: 'GROUP_ID', content: group._id},
|
||||
{name: 'GROUP_URL', content: groupUrl},
|
||||
];
|
||||
|
||||
sendTxn(FLAG_REPORT_EMAILS, 'shadow-muted-post-report-to-mods', report);
|
||||
|
||||
// Slack the mods
|
||||
slack.sendShadowMutedPostNotification({
|
||||
authorEmail,
|
||||
author: user,
|
||||
group,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
const newChatMessage = group.sendChat({message: req.body.message, user, flagCount, metaData: null, client});
|
||||
let toSave = [newChatMessage.save()];
|
||||
|
||||
if (group.type === 'party') {
|
||||
@@ -372,12 +409,12 @@ api.clearChatFlags = {
|
||||
{name: 'ADMIN_USERNAME', content: user.profile.name},
|
||||
{name: 'ADMIN_UUID', content: user._id},
|
||||
{name: 'ADMIN_EMAIL', content: adminEmailContent},
|
||||
{name: 'ADMIN_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
|
||||
{name: 'ADMIN_MODAL_URL', content: `/profile/${user._id}`},
|
||||
|
||||
{name: 'AUTHOR_USERNAME', content: message.user},
|
||||
{name: 'AUTHOR_UUID', content: message.uuid},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmail},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/profile/${message.uuid}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
|
||||
@@ -145,7 +145,7 @@ api.getHeroes = {
|
||||
// Note, while the following routes are called getHero / updateHero
|
||||
// they can be used by admins to get/update any user
|
||||
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked';
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked flags.chatShadowMuted';
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username
|
||||
@@ -213,7 +213,10 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
|
||||
* {
|
||||
* "balance": 1000,
|
||||
* "auth": {"blocked": false},
|
||||
* "flags": {"chatRevoked": true},
|
||||
* "flags": {
|
||||
* "chatRevoked": true,
|
||||
* "chatShadowMuted": true
|
||||
* },
|
||||
* "purchased": {"ads": true},
|
||||
* "contributor": {
|
||||
* "admin": true,
|
||||
@@ -286,6 +289,7 @@ api.updateHero = {
|
||||
}
|
||||
|
||||
if (updateData.flags && _.isBoolean(updateData.flags.chatRevoked)) hero.flags.chatRevoked = updateData.flags.chatRevoked;
|
||||
if (updateData.flags && _.isBoolean(updateData.flags.chatShadowMuted)) hero.flags.chatShadowMuted = updateData.flags.chatShadowMuted;
|
||||
|
||||
let savedHero = await hero.save();
|
||||
let heroJSON = savedHero.toJSON();
|
||||
|
||||
@@ -194,6 +194,49 @@ function sendSubscriptionNotification ({
|
||||
});
|
||||
}
|
||||
|
||||
function sendShadowMutedPostNotification ({
|
||||
authorEmail,
|
||||
author,
|
||||
group,
|
||||
message,
|
||||
}) {
|
||||
if (SKIP_FLAG_METHODS) {
|
||||
return;
|
||||
}
|
||||
let titleLink;
|
||||
let authorName;
|
||||
let title = `Shadow-Muted Post in ${group.name}`;
|
||||
let text = `@${author.auth.local.username} / ${author.profile.name} posted while shadow-muted`;
|
||||
|
||||
if (group.id === TAVERN_ID) {
|
||||
titleLink = `${BASE_URL}/groups/tavern`;
|
||||
} else {
|
||||
titleLink = `${BASE_URL}/groups/guild/${group.id}`;
|
||||
}
|
||||
|
||||
authorName = formatUser({
|
||||
name: author.auth.local.username,
|
||||
displayName: author.profile.name,
|
||||
email: authorEmail,
|
||||
uuid: author.id,
|
||||
});
|
||||
|
||||
flagSlack.send({
|
||||
text,
|
||||
attachments: [{
|
||||
fallback: 'Shadow-Muted Message',
|
||||
color: 'danger',
|
||||
author_name: authorName,
|
||||
title,
|
||||
title_link: titleLink,
|
||||
text: message,
|
||||
mrkdwn_in: [
|
||||
'text',
|
||||
],
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function sendSlurNotification ({
|
||||
authorEmail,
|
||||
author,
|
||||
@@ -243,6 +286,7 @@ module.exports = {
|
||||
sendFlagNotification,
|
||||
sendInboxFlagNotification,
|
||||
sendSubscriptionNotification,
|
||||
sendShadowMutedPostNotification,
|
||||
sendSlurNotification,
|
||||
formatUser,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = shared.constants.LARGE_GROUP_COUNT_MESS
|
||||
const MAX_SUMMARY_SIZE_FOR_GUILDS = shared.constants.MAX_SUMMARY_SIZE_FOR_GUILDS;
|
||||
const GUILDS_PER_PAGE = shared.constants.GUILDS_PER_PAGE;
|
||||
|
||||
const CHAT_FLAG_LIMIT_FOR_HIDING = shared.constants.CHAT_FLAG_LIMIT_FOR_HIDING;
|
||||
|
||||
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
|
||||
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
|
||||
const MAX_UPDATE_RETRIES = 5;
|
||||
@@ -367,8 +369,8 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
|
||||
chatMsg.flags = {};
|
||||
if (chatMsg._meta) chatMsg._meta = undefined;
|
||||
|
||||
// Messages with >= 2 flags are hidden to non admins and non authors
|
||||
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= 2) return undefined;
|
||||
// Messages with too many flags are hidden to non-admins and non-authors
|
||||
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) return undefined;
|
||||
}
|
||||
|
||||
return chatMsg;
|
||||
@@ -510,8 +512,8 @@ schema.methods.getMemberCount = async function getMemberCount () {
|
||||
};
|
||||
|
||||
schema.methods.sendChat = function sendChat (options = {}) {
|
||||
const {message, user, metaData, client, info = {}} = options;
|
||||
let newMessage = messageDefaults(message, user, client, info);
|
||||
const {message, user, metaData, client, flagCount = 0, info = {}} = options;
|
||||
let newMessage = messageDefaults(message, user, client, flagCount, info);
|
||||
let newChatMessage = new Chat();
|
||||
newChatMessage = Object.assign(newChatMessage, newMessage);
|
||||
newChatMessage.groupId = this._id;
|
||||
@@ -528,8 +530,11 @@ schema.methods.sendChat = function sendChat (options = {}) {
|
||||
// newChatMessage is possibly returned
|
||||
this.sendGroupChatReceivedWebhooks(newChatMessage);
|
||||
|
||||
// do not send notifications for guilds with more than 5000 users and for the tavern
|
||||
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF) {
|
||||
// do not send notifications for:
|
||||
// - groups that never send notifications (e.g., Tavern)
|
||||
// - groups with very many users
|
||||
// - messages that have already been flagged to hide them
|
||||
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF || newChatMessage.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) {
|
||||
return newChatMessage;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export function setUserStyles (newMessage, user) {
|
||||
newMessage.markModified('userStyles contributor');
|
||||
}
|
||||
|
||||
export function messageDefaults (msg, user, client, info = {}) {
|
||||
export function messageDefaults (msg, user, client, flagCount = 0, info = {}) {
|
||||
const id = uuid();
|
||||
const message = {
|
||||
id,
|
||||
@@ -118,7 +118,7 @@ export function messageDefaults (msg, user, client, info = {}) {
|
||||
timestamp: Number(new Date()),
|
||||
likes: {},
|
||||
flags: {},
|
||||
flagCount: 0,
|
||||
flagCount,
|
||||
client,
|
||||
};
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ let schema = new Schema({
|
||||
return {};
|
||||
}},
|
||||
chatRevoked: Boolean,
|
||||
chatShadowMuted: Boolean,
|
||||
// Used to track the status of recapture emails sent to each user,
|
||||
// can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user
|
||||
recaptureEmailsPhase: {$type: Number, default: 0},
|
||||
|
||||
Reference in New Issue
Block a user