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:
Alys
2019-07-31 03:09:42 +10:00
committed by Matteo Pagliazzi
parent b4b9caed83
commit c80de38572
13 changed files with 375 additions and 112 deletions

View File

@@ -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++) {

View File

@@ -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},

View File

@@ -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);
},
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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'},

View File

@@ -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;

View File

@@ -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},

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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},