diff --git a/test/api/unit/libs/slack.js b/test/api/unit/libs/slack.js index 3252ed7e16..4b6acdf695 100644 --- a/test/api/unit/libs/slack.js +++ b/test/api/unit/libs/slack.js @@ -32,6 +32,7 @@ describe('slack', () => { }, message: { id: 'chat-id', + username: 'author', user: 'Author', uuid: 'author-id', text: 'some text', @@ -50,11 +51,11 @@ describe('slack', () => { expect(IncomingWebhook.prototype.send).to.be.calledOnce; expect(IncomingWebhook.prototype.send).to.be.calledWith({ - text: 'flagger (flagger-id; language: flagger-lang) flagged a message', + text: 'flagger (flagger-id; language: flagger-lang) flagged a group message', attachments: [{ fallback: 'Flag Message', color: 'danger', - author_name: `Author - author@example.com - author-id\n${timestamp}`, + author_name: `@author Author (author@example.com; author-id)\n${timestamp}`, title: 'Flag in Some group - (private guild)', title_link: undefined, text: 'some text', diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 88b41744c8..723715c80b 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -63,11 +63,11 @@ describe('POST /chat/:chatId/flag', () => { /* eslint-disable camelcase */ expect(IncomingWebhook.prototype.send).to.be.calledWith({ - text: `${user.profile.name} (${user.id}; language: en) flagged a message`, + text: `${user.profile.name} (${user.id}; language: en) flagged a group message`, attachments: [{ fallback: 'Flag Message', color: 'danger', - author_name: `${anotherUser.profile.name} - ${anotherUser.auth.local.email} - ${anotherUser._id}\n${timestamp}`, + author_name: `@${anotherUser.auth.local.username} ${anotherUser.profile.name} (${anotherUser.auth.local.email}; ${anotherUser._id})\n${timestamp}`, title: 'Flag in Test Guild', title_link: `${BASE_URL}/groups/guild/${group._id}`, text: TEST_MESSAGE, @@ -98,11 +98,11 @@ describe('POST /chat/:chatId/flag', () => { /* eslint-disable camelcase */ expect(IncomingWebhook.prototype.send).to.be.calledWith({ - text: `${newUser.profile.name} (${newUser.id}; language: en) flagged a message`, + text: `${newUser.profile.name} (${newUser.id}; language: en) flagged a group message`, attachments: [{ fallback: 'Flag Message', color: 'danger', - author_name: `${newUser.profile.name} - ${newUser.auth.local.email} - ${newUser._id}\n${timestamp}`, + author_name: `@${newUser.auth.local.username} ${newUser.profile.name} (${newUser.auth.local.email}; ${newUser._id})\n${timestamp}`, title: 'Flag in Test Guild', title_link: `${BASE_URL}/groups/guild/${group._id}`, text: TEST_MESSAGE, diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index bd0f158b7d..aa6b8c8806 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -257,7 +257,7 @@ describe('POST /chat', () => { attachments: [{ fallback: 'Slur Message', color: 'danger', - author_name: `${user.profile.name} - ${user.auth.local.email} - ${user._id}`, + author_name: `@${user.auth.local.username} ${user.profile.name} (${user.auth.local.email}; ${user._id})`, title: 'Slur in Test Guild', title_link: `${BASE_URL}/groups/guild/${groupWithChat.id}`, text: testSlurMessage, @@ -310,7 +310,7 @@ describe('POST /chat', () => { attachments: [{ fallback: 'Slur Message', color: 'danger', - author_name: `${members[0].profile.name} - ${members[0].auth.local.email} - ${members[0]._id}`, + author_name: `@${members[0].auth.local.username} ${members[0].profile.name} (${members[0].auth.local.email}; ${members[0]._id})`, title: 'Slur in Party - (private party)', title_link: undefined, text: testSlurMessage, diff --git a/test/api/v4/members/POST-flag_private_message.test.js b/test/api/v4/members/POST-flag_private_message.test.js new file mode 100644 index 0000000000..dbeb9b5e18 --- /dev/null +++ b/test/api/v4/members/POST-flag_private_message.test.js @@ -0,0 +1,74 @@ +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +describe('POST /members/flag-private-message/:messageId', () => { + let userToSendMessage; + let messageToSend = 'Test Private Message'; + + beforeEach(async () => { + userToSendMessage = await generateUser(); + }); + + it('Allows players to flag their own private message', async () => { + let receiver = await generateUser(); + + await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + let senderMessages = await userToSendMessage.get('/inbox/messages'); + + let sendersMessageInSendersInbox = _.find(senderMessages, (message) => { + return message.uuid === receiver._id && message.text === messageToSend; + }); + + expect(sendersMessageInSendersInbox).to.exist; + await expect(userToSendMessage.post(`/members/flag-private-message/${sendersMessageInSendersInbox.id}`)).to.eventually.be.ok; + }); + + it('Flags a private message', async () => { + let receiver = await generateUser(); + + await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + let receiversMessages = await receiver.get('/inbox/messages'); + + let sendersMessageInReceiversInbox = _.find(receiversMessages, (message) => { + return message.uuid === userToSendMessage._id && message.text === messageToSend; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + await expect(receiver.post(`/members/flag-private-message/${sendersMessageInReceiversInbox.id}`)).to.eventually.be.ok; + }); + + it('Returns an error when user tries to flag a private message that is already flagged', async () => { + let receiver = await generateUser(); + + await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + let receiversMessages = await receiver.get('/inbox/messages'); + + let sendersMessageInReceiversInbox = _.find(receiversMessages, (message) => { + return message.uuid === userToSendMessage._id && message.text === messageToSend; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + await expect(receiver.post(`/members/flag-private-message/${sendersMessageInReceiversInbox.id}`)).to.eventually.be.ok; + + await expect(receiver.post(`/members/flag-private-message/${sendersMessageInReceiversInbox.id}`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('messageGroupChatFlagAlreadyReported'), + }); + }); +}); diff --git a/website/client/components/appFooter.vue b/website/client/components/appFooter.vue index 0859dc691c..89d12e0ecf 100644 --- a/website/client/components/appFooter.vue +++ b/website/client/components/appFooter.vue @@ -43,6 +43,8 @@ li(v-html='$t("communityForum")') li a(href='https://www.facebook.com/Habitica', target='_blank') {{ $t('communityFacebook') }} + li + a(href='https://www.instagram.com/habitica', target='_blank') {{ $t('communityInstagram') }} li a(href='https://www.reddit.com/r/habitrpg/', target='_blank') {{ $t('communityReddit') }} .col-12.col-md-6 @@ -56,23 +58,22 @@ a(:href="getDataDisplayToolUrl", target='_blank') {{ $t('dataDisplayTool') }} li a(href='http://habitica.fandom.com/wiki/Guidance_for_Blacksmiths', target='_blank') {{ $t('guidanceForBlacksmiths') }} - li - a(href='http://devs.habitica.com/', target='_blank') {{ $t('devBlog') }} .col-6.social h3 {{ $t('footerSocial') }} - a.social-circle(href='https://twitter.com/habitica', target='_blank') - .social-icon.svg-icon(v-html='icons.twitter') - // TODO: Not ready yet. a.social-circle(href='https://www.instagram.com/habitica/', target='_blank') - .social-icon.svg-icon.instagram(v-html='icons.instagram') - a.social-circle(href='https://www.facebook.com/Habitica', target='_blank') - .social-icon.facebook.svg-icon(v-html='icons.facebook') + .icons + a.social-circle(href='https://twitter.com/habitica', target='_blank') + .social-icon.svg-icon(v-html='icons.twitter') + a.social-circle(href='https://www.instagram.com/habitica/', target='_blank') + .social-icon.svg-icon.instagram(v-html='icons.instagram') + a.social-circle(href='https://www.facebook.com/Habitica', target='_blank') + .social-icon.facebook.svg-icon(v-html='icons.facebook') .row .col-12.col-md-8 {{ $t('donateText3') }} .col-12.col-md-4 - button.btn.btn-contribute(@click="donate()", v-if="user") + button.btn.btn-contribute.btn-flat(@click="donate()", v-if="user") .svg-icon.heart(v-html="icons.heart") .text {{ $t('companyDonate') }} - .btn.btn-contribute(v-else) + .btn.btn-contribute.btn-flat(v-else) a(href='http://habitica.fandom.com/wiki/Contributing_to_Habitica', target='_blank') .svg-icon.heart(v-html="icons.heart") .text {{ $t('companyContribute') }} @@ -145,6 +146,22 @@ } } + .icons { + display: flex; + justify-content: flex-end; + flex-shrink: 1; + } + + // smaller than desktop + @media only screen and (max-width: 992px) { + .social-circle { + height: 32px !important; + width: 32px !important; + + margin-left: 0.75em !important; + } + } + .social-circle { width: 40px; height: 40px; @@ -152,17 +169,20 @@ background-color: #c3c0c7; display: flex; margin-left: 1em; - float: right; + + &:first-child { + margin-left: 0; + } + + &:hover { + background-color: #a5a1ac; + } .social-icon { color: #e1e0e3; width: 16px; margin: auto; } - - .instagram { - margin-top: .85em; - } } .logo { @@ -185,6 +205,14 @@ box-shadow: none; border-radius: 4px; + &:hover { + background: #a5a1ac; + + .text { + color: white; + } + } + a { display: flex; } diff --git a/website/client/components/chat/chatCard.vue b/website/client/components/chat/chatCard.vue index 362a839f75..3a35fa1ace 100644 --- a/website/client/components/chat/chatCard.vue +++ b/website/client/components/chat/chatCard.vue @@ -1,8 +1,8 @@ diff --git a/website/client/components/tasks/task.vue b/website/client/components/tasks/task.vue index 16a03ed681..4d7f383f47 100644 --- a/website/client/components/tasks/task.vue +++ b/website/client/components/tasks/task.vue @@ -20,7 +20,7 @@ v-if="isUser && !isRunningYesterdailies", :right="task.type === 'reward'", ref="taskDropdown", - v-b-tooltip.hover.top="$t('showMore')" + v-b-tooltip.hover.top="$t('options')" ) div(slot="dropdown-toggle", draggable=false) .svg-icon.dropdown-icon(v-html="icons.menu") @@ -141,6 +141,11 @@ min-width: 0px; overflow-wrap: break-word; + // markdown p-tag, can't find without /deep/ + /deep/ p { + margin-bottom: 0; + } + &.has-notes { padding-bottom: 4px; } @@ -229,7 +234,7 @@ overflow-wrap: break-word; &.has-checklist { - padding-bottom: 8px; + padding-bottom: 2px; } } @@ -285,7 +290,7 @@ margin-bottom: -3px; min-height: 0px; width: 100%; - margin-left: 8px; + margin-left: 0; padding-right: 20px; overflow-wrap: break-word; @@ -427,7 +432,7 @@ border-left: none; } - .task-control, .reward-control { + .task-control:not(.task-disabled-habit-control-inner), .reward-control { cursor: pointer; } diff --git a/website/client/components/tasks/user.vue b/website/client/components/tasks/user.vue index e9652ccabc..4986ecec29 100644 --- a/website/client/components/tasks/user.vue +++ b/website/client/components/tasks/user.vue @@ -12,7 +12,7 @@ .col-12.col-md-4.offset-md-4 .d-flex input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText") - button.btn.btn-secondary.dropdown-toggle.ml-2.d-flex.align-items-center( + button.btn.btn-secondary.dropdown-toggle.ml-2.d-flex.align-items-center.search-button( type="button", @click="toggleFilterPanel()", :class="{active: selectedTags.length > 0}", @@ -110,6 +110,10 @@ padding-top: 16px; } + .input-search, .search-button { + height: 40px; + } + .tasks-navigation { margin-bottom: 20px; } diff --git a/website/client/components/ui/statsbar.vue b/website/client/components/ui/statsbar.vue new file mode 100644 index 0000000000..dc7ddb37c0 --- /dev/null +++ b/website/client/components/ui/statsbar.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/website/client/components/userMenu/profile.vue b/website/client/components/userMenu/profile.vue index cf544bfbda..e66208d0d8 100644 --- a/website/client/components/userMenu/profile.vue +++ b/website/client/components/userMenu/profile.vue @@ -324,6 +324,7 @@ } .progress-container > .progress { + border-radius: 1px; background-color: $gray-500; } } @@ -371,8 +372,10 @@ .progress { height: 8px; + border-radius: 1px; .progress-bar { + border-radius: 1px; background-color: $green-10 !important; } } @@ -495,6 +498,9 @@ export default { async userId () { this.loadUser(); }, + userLoggedIn () { + this.loadUser(); + }, }, methods: { async loadUser () { diff --git a/website/client/store/actions/chat.js b/website/client/store/actions/chat.js index 0bb568a863..584db3bf75 100644 --- a/website/client/store/actions/chat.js +++ b/website/client/store/actions/chat.js @@ -48,10 +48,18 @@ export async function like (store, payload) { } export async function flag (store, payload) { - const url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatId}/flag`; + let url = ''; + + if (payload.groupId === 'privateMessage') { + url = `/api/v4/members/flag-private-message/${payload.chatId}`; + } else { + url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatId}/flag`; + } + const response = await axios.post(url, { comment: payload.comment, }); + return response.data.data; } diff --git a/website/client/store/actions/user.js b/website/client/store/actions/user.js index d15bd3e805..fe6094fcc9 100644 --- a/website/client/store/actions/user.js +++ b/website/client/store/actions/user.js @@ -5,6 +5,7 @@ import axios from 'axios'; import { togglePinnedItem as togglePinnedItemOp } from 'common/script/ops/pinnedGearUtils'; import changeClassOp from 'common/script/ops/changeClass'; import disableClassesOp from 'common/script/ops/disableClasses'; +import openMysteryItemOp from 'common/script/ops/openMysteryItem'; export function fetch (store, options = {}) { // eslint-disable-line no-shadow return loadAsyncResource({ @@ -127,7 +128,9 @@ export function castSpell (store, params) { return axios.post(spellUrl, data); } -export function openMysteryItem () { +export async function openMysteryItem (store) { + let user = store.state.user.data; + openMysteryItemOp(user); return axios.post('/api/v4/user/open-mystery-item'); } diff --git a/website/common/errors/apiErrorMessages.js b/website/common/errors/apiErrorMessages.js index a1550fddad..f10747b12c 100644 --- a/website/common/errors/apiErrorMessages.js +++ b/website/common/errors/apiErrorMessages.js @@ -8,11 +8,14 @@ module.exports = { missingTypeKeyEquip: '"key" and "type" are required parameters.', + chatIdRequired: 'req.params.chatId must contain a chatId.', + messageIdRequired: 'req.params.messageId must contain a message ID.', + guildsOnlyPaginate: 'Only public guilds support pagination.', guildsPaginateBooleanString: 'req.query.paginate must be a boolean string.', groupIdRequired: 'req.params.groupId must contain a groupId.', groupRemainOrLeaveChallenges: 'req.query.keep must be either "remain-in-challenges" or "leave-challenges"', - managerIdRequired: 'req.body.managerId must contain a user ID.', + managerIdRequired: 'req.body.managerId must contain a User ID.', noSudoAccess: 'You don\'t have sudo access.', eventRequired: '"req.params.event" is required.', @@ -22,6 +25,4 @@ module.exports = { missingCustomerId: 'Missing "req.query.customerId"', missingPaypalBlock: 'Missing "req.session.paypalBlock"', missingSubKey: 'Missing "req.query.sub"', - - messageIdRequired: '\"messageId\" must be a valid UUID.",', }; diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index be9e61a57a..c249bc9f51 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -23,6 +23,7 @@ "communityBug": "Submit Bug", "communityExtensions": "Add-ons & Extensions", "communityFacebook": "Facebook", + "communityInstagram": "Instagram", "communityFeature": "Request Feature", "communityForum": "Forum", "communityKickstarter": "Kickstarter", diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index 57adad943b..f8fa686631 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -34,6 +34,7 @@ "saveEdits": "Save Edits", "showMore": "Show More", "showLess": "Show Less", + "options": "Options", "expandToolbar": "Expand Toolbar", "collapseToolbar": "Collapse Toolbar", diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 31b5c08887..496f1277f7 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -162,6 +162,7 @@ "abuseFlagModalBody": "Are you sure you want to report this post? You should only report a post that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Inappropriately reporting a post is a violation of the Community Guidelines and may give you an infraction.", "abuseFlagModalButton": "Report Violation", "abuseReported": "Thank you for reporting this violation. The moderators have been notified.", + "pmReported": "Thank you for reporting this message.", "abuseAlreadyReported": "You have already reported this message.", "whyReportingPost": "Why are you reporting this post?", "whyReportingPostPlaceholder": "Please help our moderators by letting us know why you are reporting this post for a violation, e.g., spam, swearing, religious oaths, bigotry, slurs, adult topics, violence.", @@ -230,9 +231,9 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "mustBeGroupMember": "Must be member of the group.", - "canOnlyInviteEmailUuid": "Can only invite using user IDs, emails, or usernames.", + "canOnlyInviteEmailUuid": "Can only invite using User IDs, emails, or usernames.", "inviteMissingEmail": "Missing email address in invite.", - "inviteMissingUuid": "Missing user id in invite", + "inviteMissingUuid": "Missing User ID in invite", "inviteMustNotBeEmpty": "Invite must not be empty.", "partyMustbePrivate": "Parties must be private", "userAlreadyInGroup": "UserID: <%= userId %>, User \"<%= username %>\" already in that group.", diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 0a0e9b0c15..b1aa99d069 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -45,7 +45,7 @@ "messageAuthEmailTaken": "Email already taken", "messageAuthNoUserFound": "No user found.", "messageAuthMustBeLoggedIn": "You must be logged in.", - "messageAuthMustIncludeTokens": "You must include a token and uid (user id) in your request", + "messageAuthMustIncludeTokens": "You must include a token and uid (User ID) in your request", "messageGroupAlreadyInParty": "Already in a party, try refreshing.", "messageGroupOnlyLeaderCanUpdate": "Only the group leader can update the group!", @@ -71,6 +71,7 @@ "beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!", "messageDeletedUser": "Sorry, this user has deleted their account.", - - "messageMissingDisplayName": "Missing display name." + "messageMissingDisplayName": "Missing display name.", + "reportedMessage": "You have reported this message to moderators.", + "canDeleteNow": "You can now delete the message if you wish." } diff --git a/website/server/controllers/api-v3/challenges.js b/website/server/controllers/api-v3/challenges.js index 37f9cac929..82bb301169 100644 --- a/website/server/controllers/api-v3/challenges.js +++ b/website/server/controllers/api-v3/challenges.js @@ -49,7 +49,7 @@ let api = {}; * @apiSuccess {String} challenge.name Full name of challenge. * @apiSuccess {String} challenge.shortName A shortened name for the challenge, to be used as a tag. * @apiSuccess {Object} challenge.leader User details of challenge leader. - * @apiSuccess {UUID} challenge.leader._id User id of challenge leader. + * @apiSuccess {UUID} challenge.leader._id User ID of challenge leader. * @apiSuccess {Object} challenge.leader.profile Profile information of leader. * @apiSuccess {Object} challenge.leader.profile.name Display Name of leader. * @apiSuccess {String} challenge.updatedAt Timestamp of last update. diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 3c7f5468b0..84703ac0da 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -30,12 +30,17 @@ const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) /** * @apiDefine GroupIdRequired - * @apiError (404) {badRequest} groupIdRequired A group ID is required + * @apiError (400) {badRequest} groupIdRequired A group ID is required */ /** * @apiDefine ChatIdRequired - * @apiError (404) {badRequest} chatIdRequired A chat ID is required + * @apiError (400) {badRequest} chatIdRequired A chat ID is required + */ + +/** + * @apiDefine MessageIdRequired + * @apiError (400) {badRequest} messageIdRequired A message ID is required */ let api = {}; @@ -246,7 +251,7 @@ api.likeChat = { let groupId = req.params.groupId; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -285,7 +290,7 @@ api.likeChat = { * @apiSuccess {Object} data.likes The likes of the message * @apiSuccess {Object} data.flags The flags of the message * @apiSuccess {Number} data.flagCount The number of flags the message has - * @apiSuccess {UUID} data.uuid The user id of the author of the message + * @apiSuccess {UUID} data.uuid The User ID of the author of the message * @apiSuccess {String} data.user The username of the author of the message * * @apiUse GroupNotFound @@ -334,7 +339,7 @@ api.clearChatFlags = { let chatId = req.params.chatId; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -470,7 +475,7 @@ api.deleteChat = { let chatId = req.params.chatId; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); - req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 166f82e430..9eef5108e9 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -941,11 +941,11 @@ api.removeGroupMember = { * {"name": "User2", "email": "user-2@example.com"} * ] * } - * @apiParamExample {json} User Ids + * @apiParamExample {json} User IDs * { * "uuids": ["user-id-of-existing-user", "user-id-of-another-existing-user"] * } - * @apiParamExample {json} User Ids and Emails + * @apiParamExample {json} User IDs and Emails * { * "emails": [ * {"email": "user-1@example.com"}, @@ -955,7 +955,7 @@ api.removeGroupMember = { * } * * @apiSuccess {Array} data The invites - * @apiSuccess {Object} data[0] If the invitation was a user id, you'll receive back an object. You'll receive one Object for each succesful user id invite. + * @apiSuccess {Object} data[0] If the invitation was a User ID, you'll receive back an object. You'll receive one Object for each succesful User ID invite. * @apiSuccess {String} data[1] If the invitation was an email, you'll receive back the email. You'll receive one String for each successful email invite. * * @apiSuccessExample {json} Successful Response with Emails @@ -966,13 +966,13 @@ api.removeGroupMember = { * ] * } * - * @apiSuccessExample {json} Successful Response with User Id + * @apiSuccessExample {json} Successful Response with User ID * { * "data": [ * { id: 'the-id-of-the-invited-user', name: 'The group name', inviter: 'your-user-id' } * ] * } - * @apiSuccessExample {json} Successful Response with User Ids and Emails + * @apiSuccessExample {json} Successful Response with User IDs and Emails * { * "data": [ * "user-1@example.com", @@ -987,9 +987,9 @@ api.removeGroupMember = { * param `Array`. * @apiError (400) {BadRequest} UuidOrEmailOnly The `emails` and `uuids` params were both missing and/or a * key other than `emails` or `uuids` was provided in the body param. - * @apiError (400) {BadRequest} CannotInviteSelf User id or email of invitee matches that of the inviter. + * @apiError (400) {BadRequest} CannotInviteSelf User ID or email of invitee matches that of the inviter. * @apiError (400) {BadRequest} MustBeArray The `uuids` or `emails` body param was not an array. - * @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and user ids) can + * @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and User IDs) can * be sent out at a time. * @apiError (400) {BadRequest} ExceedsMembersLimit A max of 30 members can join a party. * diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index e42adb80c5..d3715bfda6 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -197,7 +197,7 @@ const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; /** * @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero") - * @apiParam (Path) {UUID} heroId user ID + * @apiParam (Path) {UUID} heroId User ID * @apiName UpdateHero * @apiGroup Hall * @apiPermission Admin diff --git a/website/server/controllers/api-v4/members.js b/website/server/controllers/api-v4/members.js new file mode 100644 index 0000000000..57a1b39ddd --- /dev/null +++ b/website/server/controllers/api-v4/members.js @@ -0,0 +1,43 @@ +import { authWithHeaders } from '../../middlewares/auth'; +import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; + +let api = {}; + +/** + * @api {post} /api/v4/members/flag-private-message/:messageId Flag a private message + * @apiDescription An email and slack message are sent to the moderators about every flagged message. + * @apiName FlagPrivateMessage + * @apiGroup Member + * + * @apiParam (Path) {UUID} messageId The private message id + * + * @apiSuccess {Object} data The flagged private message + * @apiSuccess {UUID} data.id The id of the message + * @apiSuccess {String} data.text The text of the message + * @apiSuccess {Number} data.timestamp The timestamp of the message in milliseconds + * @apiSuccess {Object} data.likes The likes of the message (always an empty object) + * @apiSuccess {Object} data.flags The flags of the message + * @apiSuccess {Number} data.flagCount The number of flags the message has + * @apiSuccess {UUID} data.uuid The User ID of the author of the message, or of the recipient if `sent` is true + * @apiSuccess {String} data.user The Display Name of the author of the message, or of the recipient if `sent` is true + * @apiSuccess {String} data.username The Username of the author of the message, or of the recipient if `sent` is true + * + * @apiUse MessageNotFound + * @apiUse MessageIdRequired + * @apiError (400) {BadRequest} messageGroupChatFlagAlreadyReported You have already reported this message + */ +api.flagPrivateMessage = { + method: 'POST', + url: '/members/flag-private-message/:messageId', + middlewares: [authWithHeaders()], + async handler (req, res) { + const chatReporter = chatReporterFactory('Inbox', req, res); + const message = await chatReporter.flag(); + res.respond(200, { + ok: true, + message, + }); + }, +}; + +module.exports = api; diff --git a/website/server/libs/chatReporting/chatReporter.js b/website/server/libs/chatReporting/chatReporter.js index 148dbd4696..d55a95c353 100644 --- a/website/server/libs/chatReporting/chatReporter.js +++ b/website/server/libs/chatReporting/chatReporter.js @@ -1,6 +1,4 @@ -import { -} from '../errors'; -import { getUserInfo } from '../email'; +import { getGroupUrl, getUserInfo } from '../email'; import { getAuthorEmailFromMessage } from '../chat'; export default class ChatReporter { @@ -11,25 +9,51 @@ export default class ChatReporter { async validate () {} - async notify (group, message) { - const reporterEmailContent = getUserInfo(this.user, ['email']).email; - this.authorEmail = await getAuthorEmailFromMessage(message); - this.emailVariables = [ + async getMessageVariables (group, message) { + const reporterEmail = getUserInfo(this.user, ['email']).email; + + const authorVariables = await this.getAuthorVariables(message); + const groupUrl = getGroupUrl(group); + + return [ {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, {name: 'MESSAGE_TEXT', content: message.text}, - {name: 'REPORTER_USERNAME', content: this.user.profile.name}, + {name: 'REPORTER_DISPLAY_NAME', content: this.user.profile.name}, + {name: 'REPORTER_USERNAME', content: this.user.auth.local.username}, {name: 'REPORTER_UUID', content: this.user._id}, - {name: 'REPORTER_EMAIL', content: reporterEmailContent}, + {name: 'REPORTER_EMAIL', content: reporterEmail}, {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${this.user._id}`}, - {name: 'AUTHOR_USERNAME', content: message.user}, - {name: 'AUTHOR_UUID', content: message.uuid}, - {name: 'AUTHOR_EMAIL', content: this.authorEmail}, - {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`}, + ...authorVariables, + + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl || 'N/A'}, ]; } + createGenericAuthorVariables (prefix, {user, username, uuid, email}) { + return [ + {name: `${prefix}_DISPLAY_NAME`, content: user}, + {name: `${prefix}_USERNAME`, content: username}, + {name: `${prefix}_UUID`, content: uuid}, + {name: `${prefix}_EMAIL`, content: email}, + {name: `${prefix}_MODAL_URL`, content: `/static/front/#?memberId=${uuid}`}, + ]; + } + + async getAuthorVariables (message) { + this.authorEmail = await getAuthorEmailFromMessage(message); + return this.createGenericAuthorVariables('AUTHOR', { + user: message.user, + username: message.username, + uuid: message.uuid, + email: this.authorEmail, + }); + } + async flag () { throw new Error('Flag must be implemented'); } diff --git a/website/server/libs/chatReporting/chatReporterFactory.js b/website/server/libs/chatReporting/chatReporterFactory.js index cd97da6793..50ac7e9e89 100644 --- a/website/server/libs/chatReporting/chatReporterFactory.js +++ b/website/server/libs/chatReporting/chatReporterFactory.js @@ -1,11 +1,10 @@ import GroupChatReporter from './groupChatReporter'; -// import InboxChatReporter from './inboxChatReporter'; +import InboxChatReporter from './inboxChatReporter'; export function chatReporterFactory (type, req, res) { if (type === 'Group') { return new GroupChatReporter(req, res); + } else if (type === 'Inbox') { + return new InboxChatReporter(req, res); } - // else if (type === 'Inbox') { - // return new InboxChatReporter(req, res); - // } } diff --git a/website/server/libs/chatReporting/groupChatReporter.js b/website/server/libs/chatReporting/groupChatReporter.js index ff625bfb8a..b41f142579 100644 --- a/website/server/libs/chatReporting/groupChatReporter.js +++ b/website/server/libs/chatReporting/groupChatReporter.js @@ -6,7 +6,7 @@ import { BadRequest, NotFound, } from '../errors'; -import { getGroupUrl, sendTxn } from '../email'; +import { sendTxn } from '../email'; import slack from '../slack'; import { model as Group } from '../../models/group'; import { chatModel as Chat } from '../../models/message'; @@ -28,7 +28,7 @@ export default class GroupChatReporter extends ChatReporter { async validate () { this.req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); - this.req.checkParams('chatId', this.res.t('chatIdRequired')).notEmpty(); + this.req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); let validationErrors = this.req.validationErrors(); if (validationErrors) throw validationErrors; @@ -50,16 +50,12 @@ export default class GroupChatReporter extends ChatReporter { } async notify (group, message, userComment, automatedComment = '') { - await super.notify(group, message); - - const groupUrl = getGroupUrl(group); - sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods-with-comments', this.emailVariables.concat([ - {name: 'GROUP_NAME', content: group.name}, - {name: 'GROUP_TYPE', content: group.type}, - {name: 'GROUP_ID', content: group._id}, - {name: 'GROUP_URL', content: groupUrl}, + let emailVariables = await this.getMessageVariables(group, message); + emailVariables = emailVariables.concat([ {name: 'REPORTER_COMMENT', content: userComment || ''}, - ])); + ]); + + sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods-with-comments', emailVariables); slack.sendFlagNotification({ authorEmail: this.authorEmail, diff --git a/website/server/libs/chatReporting/inboxChatReporter.js b/website/server/libs/chatReporting/inboxChatReporter.js new file mode 100644 index 0000000000..207b570fd3 --- /dev/null +++ b/website/server/libs/chatReporting/inboxChatReporter.js @@ -0,0 +1,129 @@ +import nconf from 'nconf'; +import { model as User } from '../../models/user'; + +import ChatReporter from './chatReporter'; +import { + BadRequest, +} from '../errors'; +import { getUserInfo, sendTxn} from '../email'; +import slack from '../slack'; +import apiError from '../apiError'; + +import * as inboxLib from '../inbox'; +import {getAuthorEmailFromMessage} from '../chat'; + +const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { + return { email, canSend: true }; +}); + +export default class InboxChatReporter extends ChatReporter { + constructor (req, res) { + super(req, res); + + this.user = res.locals.user; + this.inboxUser = res.locals.user; + } + + async validate () { + this.req.checkParams('messageId', apiError('messageIdRequired')).notEmpty(); + + let validationErrors = this.req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (this.user.contributor.admin && this.req.query.userId) { + this.inboxUser = await User.findOne({_id: this.req.query.userId}); + } + + const message = await inboxLib.getUserInboxMessage(this.inboxUser, this.req.params.messageId); + if (!message) throw new BadRequest(this.res.t('messageGroupChatNotFound')); + + const userComment = this.req.body.comment; + + return {message, userComment}; + } + + async notify (message, userComment) { + const group = { + type: 'private messages', + name: 'N/A', + _id: 'N/A', + }; + + let emailVariables = await this.getMessageVariables(group, message); + emailVariables = emailVariables.concat([ + {name: 'REPORTER_COMMENT', content: userComment || ''}, + ]); + + sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods-with-comments', emailVariables); + + slack.sendInboxFlagNotification({ + authorEmail: this.authorEmail, + flagger: this.user, + message, + userComment, + }); + } + + async getAuthorVariables (message) { + const messageUser = { + user: message.user, + username: message.username, + uuid: message.uuid, + email: await getAuthorEmailFromMessage(message), + }; + + const reporter = { + user: this.user.profile.name, + username: this.user.auth.local.username, + uuid: this.user._id, + email: getUserInfo(this.user, ['email']).email, + }; + + // if message.sent, the reporter is the author of this message + const sendingUser = message.sent ? reporter : messageUser; + const recipient = message.sent ? messageUser : reporter; + + this.authorEmail = sendingUser.email; + + return [ + ...this.createGenericAuthorVariables('AUTHOR', sendingUser), + ...this.createGenericAuthorVariables('RECIPIENT', recipient), + ]; + } + + updateMessageAndSave (message, ...changedFields) { + for (const changedField of changedFields) { + message.markModified(changedField); + } + + return message.save(); + } + + flagInboxMessage (message) { + // Log user ids that have flagged the message + if (!message.flags) message.flags = {}; + // TODO fix error type + if (message.flags[this.user._id] && !this.user.contributor.admin) { + throw new BadRequest(this.res.t('messageGroupChatFlagAlreadyReported')); + } + + message.flags[this.user._id] = true; + message.flagCount = 1; + + return this.updateMessageAndSave(message, 'flags', 'flagCount'); + } + + async markMessageAsReported (message) { + message.reported = true; + + return this.updateMessageAndSave(message, 'reported'); + } + + async flag () { + let {message, userComment} = await this.validate(); + await this.flagInboxMessage(message); + await this.notify(message, userComment); + await this.markMessageAsReported(message); + return message; + } +} diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js index cbdda9d8e2..26524ecac0 100644 --- a/website/server/libs/inbox/index.js +++ b/website/server/libs/inbox/index.js @@ -16,6 +16,10 @@ export async function getUserInbox (user, asArray = true) { } } +export async function getUserInboxMessage (user, messageId) { + return Inbox.findOne({ownerId: user._id, _id: messageId}).exec(); +} + export async function deleteMessage (user, messageId) { const message = await Inbox.findOne({_id: messageId, ownerId: user._id }).exec(); if (!message) return false; diff --git a/website/server/libs/setupPassport.js b/website/server/libs/setupPassport.js index 9f17200fd1..215c91daa5 100644 --- a/website/server/libs/setupPassport.js +++ b/website/server/libs/setupPassport.js @@ -6,7 +6,7 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; // Passport session setup. // To support persistent login sessions, Passport needs to be able to // serialize users into and deserialize users out of the session. Typically, -// this will be as simple as storing the user ID when serializing, and finding +// this will be as simple as storing the User ID when serializing, and finding // the user by ID when deserializing. However, since this example does not // have a database of user records, the complete Facebook profile is serialized // and deserialized. diff --git a/website/server/libs/slack.js b/website/server/libs/slack.js index b0b5bb8898..0adaf24894 100644 --- a/website/server/libs/slack.js +++ b/website/server/libs/slack.js @@ -9,6 +9,10 @@ const SLACK_FLAGGING_URL = nconf.get('SLACK_FLAGGING_URL'); const SLACK_FLAGGING_FOOTER_LINK = nconf.get('SLACK_FLAGGING_FOOTER_LINK'); const SLACK_SUBSCRIPTIONS_URL = nconf.get('SLACK_SUBSCRIPTIONS_URL'); const BASE_URL = nconf.get('BASE_URL'); +const IS_PRODUCTION = nconf.get('IS_PROD'); + +const SKIP_FLAG_METHODS = IS_PRODUCTION && !SLACK_FLAGGING_URL; +const SKIP_SUB_METHOD = IS_PRODUCTION && !SLACK_SUBSCRIPTIONS_URL; let flagSlack; let subscriptionSlack; @@ -18,6 +22,26 @@ try { subscriptionSlack = new IncomingWebhook(SLACK_SUBSCRIPTIONS_URL); } catch (err) { logger.error(err); + + if (!IS_PRODUCTION) { + flagSlack = subscriptionSlack = { + send (data) { + logger.info('Data sent to slack', data); + }, + }; + } +} + +/** + * + * @param formatObj.name userName + * @param formatObj.displayName displayName + * @param formatObj.email email + * @param formatObj.uuid uuid + * @returns {string} + */ +function formatUser (formatObj) { + return `@${formatObj.name} ${formatObj.displayName} (${formatObj.email}; ${formatObj.uuid})`; } function sendFlagNotification ({ @@ -28,13 +52,13 @@ function sendFlagNotification ({ userComment, automatedComment, }) { - if (!SLACK_FLAGGING_URL) { + if (SKIP_FLAG_METHODS) { return; } let titleLink; let authorName; let title = `Flag in ${group.name}`; - let text = `${flagger.profile.name} (${flagger.id}; language: ${flagger.preferences.language}) flagged a message`; + let text = `${flagger.profile.name} (${flagger.id}; language: ${flagger.preferences.language}) flagged a group message`; let footer = `<${SLACK_FLAGGING_FOOTER_LINK}?groupId=${group.id}&chatId=${message.id}|Flag this message.>`; if (userComment) { @@ -55,7 +79,12 @@ function sendFlagNotification ({ if (!message.user && message.uuid === 'system') { authorName = 'System Message'; } else { - authorName = `${message.user} - ${authorEmail} - ${message.uuid}`; + authorName = formatUser({ + name: message.username, + displayName: message.user, + email: authorEmail, + uuid: message.uuid, + }); } const timestamp = `${moment(message.timestamp).utc().format('YYYY-MM-DD HH:mm')} UTC`; @@ -77,6 +106,69 @@ function sendFlagNotification ({ }); } +function sendInboxFlagNotification ({ + authorEmail, + flagger, + message, + userComment, +}) { + if (SKIP_FLAG_METHODS) { + return; + } + let titleLink = ''; + let authorName; + let title = `Flag in ${flagger.profile.name}'s Inbox`; + let text = `${flagger.profile.name} (${flagger.id}; language: ${flagger.preferences.language}) flagged a PM`; + let footer = ''; + + if (userComment) { + text += ` and commented: ${userComment}`; + } + + let messageText = message.text; + let sender = ''; + let recipient = ''; + + const flaggerFormat = formatUser({ + displayName: flagger.profile.name, + name: flagger.auth.local.username, + email: flagger.auth.local.email, + uuid: flagger._id, + }); + const messageUserFormat = formatUser({ + displayName: message.user, + name: message.username, + email: authorEmail, + uuid: message.uuid, + }); + + if (message.sent) { + sender = flaggerFormat; + recipient = messageUserFormat; + } else { + sender = messageUserFormat; + recipient = flaggerFormat; + } + + authorName = `${sender} wrote this message to ${recipient}.`; + + flagSlack.send({ + text, + attachments: [{ + fallback: 'Flag Message', + color: 'danger', + author_name: authorName, + title, + title_link: titleLink, + text: messageText, + footer, + mrkdwn_in: [ + 'text', + ], + }], + }); +} + function sendSubscriptionNotification ({ buyer, recipient, @@ -84,7 +176,7 @@ function sendSubscriptionNotification ({ months, groupId, }) { - if (!SLACK_SUBSCRIPTIONS_URL) { + if (SKIP_SUB_METHOD) { return; } let text; @@ -108,7 +200,7 @@ function sendSlurNotification ({ group, message, }) { - if (!SLACK_FLAGGING_URL) { + if (SKIP_FLAG_METHODS) { return; } let titleLink; @@ -124,7 +216,12 @@ function sendSlurNotification ({ title += ` - (${group.privacy} ${group.type})`; } - authorName = `${author.profile.name} - ${authorEmail} - ${author.id}`; + authorName = formatUser({ + name: author.auth.local.username, + displayName: author.profile.name, + email: authorEmail, + uuid: author.id, + }); flagSlack.send({ text, @@ -143,5 +240,9 @@ function sendSlurNotification ({ } module.exports = { - sendFlagNotification, sendSubscriptionNotification, sendSlurNotification, + sendFlagNotification, + sendInboxFlagNotification, + sendSubscriptionNotification, + sendSlurNotification, + formatUser, }; diff --git a/website/server/models/group.js b/website/server/models/group.js index cea35d21ad..e8bf77e39a 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -408,7 +408,7 @@ function getInviteCount (uuids, emails) { /** * Checks invitation uuids and emails for possible errors. * - * @param uuids An array of user ids + * @param uuids An array of User IDs * @param emails An array of emails * @param res Express res object for use with translations * @throws BadRequest An error describing the issue with the invitations