diff --git a/.gitignore b/.gitignore index f80fc6ce3dc..817161d4153 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,5 @@ webpack.webstorm.config # mongodb replica set for local dev mongodb-*.tgz -/mongodb-data +/mongodb-data* /.nyc_output diff --git a/package.json b/package.json index 36eb6cbeded..5145b950bbb 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "start:simple": "node ./website/server/index.js", "debug": "gulp nodemon --inspect", "mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet", + "mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --number 1 --quiet", "postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install", "apidoc": "gulp apidoc", "heroku-postbuild": ".heroku/report_deploy.sh" diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/inbox/POST-send_private_message.test.js similarity index 100% rename from test/api/v3/integration/members/POST-send_private_message.test.js rename to test/api/v3/integration/inbox/POST-send_private_message.test.js diff --git a/test/api/v3/integration/members/GET-members_username.test.js b/test/api/v3/integration/members/GET-members_username.test.js new file mode 100644 index 00000000000..e033bb61082 --- /dev/null +++ b/test/api/v3/integration/members/GET-members_username.test.js @@ -0,0 +1,56 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import common from '../../../../../website/common'; + +describe('GET /members/username/:username', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('validates req.params.username', async () => { + await expect(user.get('/members/username/')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns a member\'s public data only', async () => { + // make sure user has all the fields that can be returned by the getMember call + const member = await generateUser({ + contributor: { level: 1 }, + backer: { tier: 3 }, + preferences: { + costume: false, + background: 'volcano', + }, + secret: { + text: 'Clark Kent', + }, + }); + const memberRes = await user.get(`/members/username/${member.auth.local.username}`); + expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', + 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', + ]); + expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']); + expect(Object.keys(memberRes.preferences).sort()).to.eql([ + 'size', 'hair', 'skin', 'shirt', + 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', + ].sort()); + + expect(memberRes.stats.maxMP).to.exist; + expect(memberRes.stats.maxHealth).to.equal(common.maxHealth); + expect(memberRes.stats.toNextLevel).to.equal(common.tnl(memberRes.stats.lvl)); + expect(memberRes.inbox.optOut).to.exist; + expect(memberRes.inbox.canReceive).to.exist; + expect(memberRes.inbox.messages).to.not.exist; + expect(memberRes.secret).to.not.exist; + + expect(memberRes.blocks).to.not.exist; + }); +}); diff --git a/test/api/v4/members/POST-flag_private_message.test.js b/test/api/v4/inbox/POST-flag_private_message.test.js similarity index 100% rename from test/api/v4/members/POST-flag_private_message.test.js rename to test/api/v4/inbox/POST-flag_private_message.test.js diff --git a/test/api/v4/inbox/POST-inbox_message_like.test.js b/test/api/v4/inbox/POST-inbox_message_like.test.js new file mode 100644 index 00000000000..5332d3468bd --- /dev/null +++ b/test/api/v4/inbox/POST-inbox_message_like.test.js @@ -0,0 +1,104 @@ +import find from 'lodash/find'; +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +/** + * Checks the messages array if the uniqueMessageId has the like flag + * @param {InboxMessage[]} messages + * @param {String} uniqueMessageId + * @param {String} userId + * @param {Boolean} likeStatus + */ +function expectMessagesLikeStatus (messages, uniqueMessageId, userId, likeStatus) { + const messageToCheck = find(messages, { uniqueMessageId }); + + expect(messageToCheck.likes[userId]).to.equal(likeStatus); +} + +// eslint-disable-next-line mocha/no-exclusive-tests +describe('POST /inbox/like-private-message/:messageId', () => { + let userToSendMessage; + const getLikeUrl = messageId => `/inbox/like-private-message/${messageId}`; + + before(async () => { + userToSendMessage = await generateUser(); + }); + + it('returns an error when private message is not found', async () => { + await expect(userToSendMessage.post(getLikeUrl('some-unknown-id'))) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('likes a message', async () => { + const receiver = await generateUser(); + + const sentMessageResult = await userToSendMessage.post('/members/send-private-message', { + message: 'some message :)', + toUserId: receiver._id, + }); + + const { uniqueMessageId } = sentMessageResult.message; + + const likeResult = await receiver.post(getLikeUrl(uniqueMessageId)); + expect(likeResult.likes[receiver._id]).to.equal(true); + + const senderMessages = await userToSendMessage.get('/inbox/messages'); + + expectMessagesLikeStatus(senderMessages, uniqueMessageId, receiver._id, true); + + const receiversMessages = await receiver.get('/inbox/messages'); + + expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true); + }); + + it('allows a user to like their own private message', async () => { + const receiver = await generateUser(); + + const sentMessageResult = await userToSendMessage.post('/members/send-private-message', { + message: 'some message :)', + toUserId: receiver._id, + }); + + const { uniqueMessageId } = sentMessageResult.message; + + const likeResult = await userToSendMessage.post(getLikeUrl(uniqueMessageId)); + expect(likeResult.likes[userToSendMessage._id]).to.equal(true); + + const messages = await userToSendMessage.get('/inbox/messages'); + expectMessagesLikeStatus(messages, uniqueMessageId, userToSendMessage._id, true); + + const receiversMessages = await receiver.get('/inbox/messages'); + + expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true); + }); + + it('unlikes a message', async () => { + const receiver = await generateUser(); + + const sentMessageResult = await userToSendMessage.post('/members/send-private-message', { + message: 'some message :)', + toUserId: receiver._id, + }); + + const { uniqueMessageId } = sentMessageResult.message; + + const likeResult = await receiver.post(getLikeUrl(uniqueMessageId)); + + expect(likeResult.likes[receiver._id]).to.equal(true); + + const unlikeResult = await receiver.post(getLikeUrl(uniqueMessageId)); + + expect(unlikeResult.likes[receiver._id]).to.equal(false); + + const messages = await userToSendMessage.get('/inbox/messages'); + + const messageToCheck = find(messages, { id: sentMessageResult.message.id }); + expect(messageToCheck.likes[receiver._id]).to.equal(false); + }); +}); diff --git a/website/client/.eslintrc.js b/website/client/.eslintrc.js index e655655cb07..7a44ad03e3e 100644 --- a/website/client/.eslintrc.js +++ b/website/client/.eslintrc.js @@ -7,7 +7,7 @@ module.exports = { extends: [ 'habitrpg/lib/vue', ], - ignorePatterns: ['dist/', 'node_modules/'], + ignorePatterns: ['dist/', 'node_modules/', '*.d.ts'], rules: { 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', diff --git a/website/client/src/assets/scss/button.scss b/website/client/src/assets/scss/button.scss index cc63efed970..837cb899673 100644 --- a/website/client/src/assets/scss/button.scss +++ b/website/client/src/assets/scss/button.scss @@ -101,8 +101,7 @@ .btn-secondary, .dropdown > .btn-secondary.dropdown-toggle:not(.btn-success), -.show > .btn-secondary.dropdown-toggle:not(.btn-success) -{ +.show > .btn-secondary.dropdown-toggle:not(.btn-success) { background: $white; border: 2px solid transparent; color: $gray-50; @@ -298,6 +297,16 @@ box-shadow: none; } +.btn-flat, +.dropdown > .btn-flat.dropdown-toggle:not(.btn-success), +.show > .btn-flat.dropdown-toggle:not(.btn-success) { + &.with-icon { + .svg-icon.color { + color: var(--icon-color); + } + } +} + .btn-cancel { color: $blue-10; } diff --git a/website/client/src/assets/scss/dropdown.scss b/website/client/src/assets/scss/dropdown.scss index 5626947a214..570553e53b4 100644 --- a/website/client/src/assets/scss/dropdown.scss +++ b/website/client/src/assets/scss/dropdown.scss @@ -38,7 +38,12 @@ border-radius: 2px; box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24); padding: 0; +} +.no-min-width { + .dropdown-menu { + min-width: 0 !important; + } } // shared dropdown-item styles @@ -54,6 +59,8 @@ color: $gray-50 !important; cursor: pointer; + --dropdown-item-hover-icon-color: #{$gray-200}; + &:focus { outline: none; background-color: inherit; @@ -88,7 +95,7 @@ &:not(:hover) { .with-icon .svg-icon { - color: $gray-200; + color: var(dropdown-item-hover-icon-color); } } } @@ -151,7 +158,7 @@ // selectList.vue items sizing .selectListItem .dropdown-item { - padding: 0.25rem 0.75rem; + padding: 0.25rem 1rem 0.25rem 0.75rem; height: 32px; &:active, &:hover, &:focus, &.active { diff --git a/website/client/src/components/avatar.vue b/website/client/src/components/avatar.vue index ca4d32ebd7e..702f3667f0e 100644 --- a/website/client/src/components/avatar.vue +++ b/website/client/src/components/avatar.vue @@ -3,7 +3,7 @@ v-if="member.preferences" class="avatar" :style="{width, height, paddingTop}" - :class="backgroundClass" + :class="topLevelClassList" @click.prevent="castEnd()" >
- + @@ -96,15 +101,23 @@ .avatar { width: 141px; - height: 147px; image-rendering: pixelated; position: relative; cursor: pointer; + + &.centered-avatar { + margin: 0 auto; + } + + // resetting the additional padding + margin-bottom: -0.5rem !important; } .character-sprites { width: 90px; height: 90px; + + display: inline-flex; } .character-sprites span { @@ -123,6 +136,22 @@ .invert { filter: invert(100%); } + + .debug { + border: 1px solid red; + + .character-sprites { + border: 1px solid blue; + } + + .weapon { + border: 1px solid green; + } + + span { + border: 1px solid yellow; + } + } diff --git a/website/client/src/components/chat/chatMessages.vue b/website/client/src/components/chat/chatMessages.vue index 70617b74969..019e3ea00aa 100644 --- a/website/client/src/components/chat/chatMessages.vue +++ b/website/client/src/components/chat/chatMessages.vue @@ -3,15 +3,6 @@ ref="container" class="container-fluid" > -
-
- -
-
@@ -33,6 +24,8 @@
-
- -
+ diff --git a/website/client/src/components/groups/chat.vue b/website/client/src/components/groups/chat.vue index 3cca86a6270..213312977d7 100644 --- a/website/client/src/components/groups/chat.vue +++ b/website/client/src/components/groups/chat.vue @@ -22,13 +22,13 @@ :placeholder="placeholder" :class="{'user-entry': newMessage}" :maxlength="MAX_MESSAGE_LENGTH" - @keydown="updateCarretPosition" + @keydown="autoCompleteMixinUpdateCarretPosition" @keyup.ctrl.enter="sendMessageShortcut()" - @keydown.tab="handleTab($event)" - @keydown.up="selectPreviousAutocomplete($event)" - @keydown.down="selectNextAutocomplete($event)" - @keypress.enter="selectAutocomplete($event)" - @keydown.esc="handleEscape($event)" + @keydown.tab="autoCompleteMixinHandleTab($event)" + @keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)" + @keydown.down="autoCompleteMixinSelectNextAutocomplete($event)" + @keypress.enter="autoCompleteMixinSelectAutocomplete($event)" + @keydown.esc="autoCompleteMixinHandleEscape($event)" @paste="disableMessageSendShortcut()" > {{ currentLength }} / {{ MAX_MESSAGE_LENGTH }} @@ -36,8 +36,8 @@ ref="autocomplete" :text="newMessage" :textbox="textbox" - :coords="coords" - :caret-position="caretPosition" + :coords="mixinData.autoComplete.coords" + :caret-position="mixinData.autoComplete.caretPosition" :chat="group.chat" @select="selectedAutocomplete" /> @@ -74,7 +74,7 @@
- diff --git a/website/client/src/components/messages/messageCard.vue b/website/client/src/components/messages/messageCard.vue index a1f18559444..17908b03b3a 100644 --- a/website/client/src/components/messages/messageCard.vue +++ b/website/client/src/components/messages/messageCard.vue @@ -1,73 +1,157 @@ @@ -128,4 +140,12 @@ export default { margin-bottom: 0; } +/* this removes safari "save username" UI, we only search for one, we dont want to save it */ +input::-webkit-contacts-auto-fill-button, +input::-webkit-credentials-auto-fill-button { + visibility: hidden; + position: absolute; + right: 0; +} + diff --git a/website/client/src/components/userLink.vue b/website/client/src/components/userLink.vue index f149d9a6b38..9980f4c1b7a 100644 --- a/website/client/src/components/userLink.vue +++ b/website/client/src/components/userLink.vue @@ -29,20 +29,12 @@ @import '~@/assets/scss/colors.scss'; .user-link { // this is the user name - font-family: 'Roboto Condensed', sans-serif; font-weight: bold; margin-bottom: 0; cursor: pointer; - display: inline-block; - font-size: 16px; - - // currently used in the member-details-new.vue - &.smaller { - font-family: Roboto; - font-size: 14px; - font-weight: bold; - line-height: 1.71; - } + font-size: 14px; + line-height: 1.71; + display: inline-flex !important; &.no-tier { color: $gray-50; @@ -111,7 +103,6 @@ export default { 'backer', 'contributor', 'hideTooltip', - 'smallerStyle', 'showBuffed', 'context', ], @@ -173,7 +164,7 @@ export default { return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || ''; }, levelStyle () { - return `${this.userLevelStyleFromLevel(this.level, this.isNPC)} ${this.smallerStyle ? 'smaller' : ''}`; + return `${this.userLevelStyleFromLevel(this.level, this.isNPC)}`; }, }, }; diff --git a/website/client/src/mixins/autoCompleteHelper.js b/website/client/src/mixins/autoCompleteHelper.js new file mode 100644 index 00000000000..d2651df88d5 --- /dev/null +++ b/website/client/src/mixins/autoCompleteHelper.js @@ -0,0 +1,102 @@ +import debounce from 'lodash/debounce'; + +export const autoCompleteHelperMixin = { + data () { + return { + mixinData: { + autoComplete: { + caretPosition: 0, + coords: { + TOP: 0, + LEFT: 0, + }, + }, + }, + }; + }, + methods: { + autoCompleteMixinHandleTab (e) { + if (this.$refs.autocomplete.searchActive) { + e.preventDefault(); + if (e.shiftKey) { + this.$refs.autocomplete.selectPrevious(); + } else { + this.$refs.autocomplete.selectNext(); + } + } + }, + + autoCompleteMixinHandleEscape (e) { + if (this.$refs.autocomplete.searchActive) { + e.preventDefault(); + this.$refs.autocomplete.cancel(); + } + }, + + autoCompleteMixinSelectNextAutocomplete (e) { + if (this.$refs.autocomplete.searchActive) { + e.preventDefault(); + this.$refs.autocomplete.selectNext(); + } + }, + + autoCompleteMixinSelectPreviousAutocomplete (e) { + if (this.$refs.autocomplete.searchActive) { + e.preventDefault(); + this.$refs.autocomplete.selectPrevious(); + } + }, + + autoCompleteMixinSelectAutocomplete (e) { + if (this.$refs.autocomplete.searchActive) { + if (this.$refs.autocomplete.selected !== null) { + e.preventDefault(); + this.$refs.autocomplete.makeSelection(); + } else { + // no autocomplete selected, newline instead + this.$refs.autocomplete.cancel(); + } + } + }, + + autoCompleteMixinUpdateCarretPosition: debounce(function updateCarretPosition (eventUpdate) { + this._updateCarretPosition(eventUpdate); + }, 250), + + autoCompleteMixinResetCoordsPosition () { + this.mixinData.autoComplete.coords = { + TOP: 0, + LEFT: 0, + }; + }, + + // https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a + _getCoord (e, text) { + const caretPosition = text.selectionEnd; + this.mixinData.autoComplete.caretPosition = caretPosition; + + const div = document.createElement('div'); + const span = document.createElement('span'); + const copyStyle = getComputedStyle(text); + + [].forEach.call(copyStyle, prop => { + div.style[prop] = copyStyle[prop]; + }); + + div.style.position = 'absolute'; + document.body.appendChild(div); + div.textContent = text.value.substr(0, caretPosition); + span.textContent = text.value.substr(caretPosition) || '.'; + div.appendChild(span); + this.mixinData.autoComplete.coords = { + TOP: span.offsetTop, + LEFT: span.offsetLeft, + }; + document.body.removeChild(div); + }, + _updateCarretPosition (eventUpdate) { + const text = eventUpdate.target; + this._getCoord(eventUpdate, text); + }, + }, +}; diff --git a/website/client/src/mixins/copyToClipboard.js b/website/client/src/mixins/copyToClipboard.js index 68246310f58..7e5888ea8d1 100644 --- a/website/client/src/mixins/copyToClipboard.js +++ b/website/client/src/mixins/copyToClipboard.js @@ -1,7 +1,7 @@ -import notifications from './notifications'; +import { NotificationMixins } from './notifications'; -export default { - mixins: [notifications], +export const CopyToClipboardMixin = { + mixins: [NotificationMixins], methods: { async mixinCopyToClipboard (valueToCopy, notificationToShow = null) { if (navigator.clipboard) { @@ -21,3 +21,5 @@ export default { }, }, }; + +export default CopyToClipboardMixin; diff --git a/website/client/src/pages/private-messages.vue b/website/client/src/pages/private-messages/index.vue similarity index 56% rename from website/client/src/pages/private-messages.vue rename to website/client/src/pages/private-messages/index.vue index 70d48a0baf4..e733c75bf96 100644 --- a/website/client/src/pages/private-messages.vue +++ b/website/client/src/pages/private-messages/index.vue @@ -15,12 +15,26 @@ > {{ $t('messages') }} -
- -
+ +
+ +
-
- -
+
+
-
-
-

- {{ $t('emptyMessagesLine1') }} -

-

- {{ $t('emptyMessagesLine2') }} -

-
+ {{ $t('PMDisabledCaptionTitle') }}.   + {{ $t('PMDisabledCaptionText') }}
+ + + + +
@@ -113,20 +120,7 @@

-
- -

{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}

-

{{ $t('beginningOfConversationReminder') }}

-
+ + +
-

{{ disabledTexts.title }}

-

{{ disabledTexts.description }}

-
-
@@ -174,7 +170,7 @@ :class="{'disabled':newMessageDisabled || newMessage === ''}" @click="sendPrivateMessage()" > - {{ $t('send') }} + {{ $t('sendMessage') }}
@@ -184,145 +180,85 @@ - - - h3 { - margin: 0rem; + diff --git a/website/client/src/components/messages/conversationItem.vue b/website/client/src/pages/private-messages/pm-conversation-item.vue similarity index 97% rename from website/client/src/components/messages/conversationItem.vue rename to website/client/src/pages/private-messages/pm-conversation-item.vue index 9de47e2c542..3a7f31b0b3e 100644 --- a/website/client/src/components/messages/conversationItem.vue +++ b/website/client/src/pages/private-messages/pm-conversation-item.vue @@ -62,7 +62,7 @@ + + diff --git a/website/client/src/pages/private-messages/pm-disabled-state.vue b/website/client/src/pages/private-messages/pm-disabled-state.vue new file mode 100644 index 00000000000..480e1f194ae --- /dev/null +++ b/website/client/src/pages/private-messages/pm-disabled-state.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/website/client/src/pages/private-messages/pm-empty-state.vue b/website/client/src/pages/private-messages/pm-empty-state.vue new file mode 100644 index 00000000000..d8edaf4aa38 --- /dev/null +++ b/website/client/src/pages/private-messages/pm-empty-state.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/website/client/src/pages/private-messages/pm-new-message-started.vue b/website/client/src/pages/private-messages/pm-new-message-started.vue new file mode 100644 index 00000000000..433e8f44a8f --- /dev/null +++ b/website/client/src/pages/private-messages/pm-new-message-started.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/website/client/src/pages/private-messages/privateMessages.d.ts b/website/client/src/pages/private-messages/privateMessages.d.ts new file mode 100644 index 00000000000..8cc517b005e --- /dev/null +++ b/website/client/src/pages/private-messages/privateMessages.d.ts @@ -0,0 +1,44 @@ +export namespace PrivateMessages { + // Shared properties between message types + interface SharedMessageProps { + username: string; + contributor: Record; + userStyles: Record; + canReceive: boolean; + } + + /** + * This is the Type we get from our API + */ + interface ConversationSummaryMessageEntry extends SharedMessageProps { + uuid: string; + user: string; + + timestamp: string; + text: string; + count: number; + } + + /** + * The Visual (Sidebar) Entry + */ + interface ConversationEntry extends SharedMessageProps { + /** + * UUID + */ + key: string; + name: string; + + + lastMessageText: '', + canLoadMore: boolean; + page: 0 + } + + /** + * Loaded Private Messages, partial type + */ + interface PrivateMessageEntry extends SharedMessageProps { + text: string; + } +} diff --git a/website/client/src/pages/private-messages/start-new-conversation-input-header.vue b/website/client/src/pages/private-messages/start-new-conversation-input-header.vue new file mode 100644 index 00000000000..3d27ed764d7 --- /dev/null +++ b/website/client/src/pages/private-messages/start-new-conversation-input-header.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/website/client/src/router/index.js b/website/client/src/router/index.js index 538b4d6b26f..4c500258926 100644 --- a/website/client/src/router/index.js +++ b/website/client/src/router/index.js @@ -49,7 +49,7 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation'); const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing'); -const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages'); +const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages/index.vue'); // Challenges const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index'); diff --git a/website/client/src/store/actions/chat.js b/website/client/src/store/actions/chat.js index 08e5ec21360..855eba8696f 100644 --- a/website/client/src/store/actions/chat.js +++ b/website/client/src/store/actions/chat.js @@ -43,7 +43,14 @@ export async function deleteChat (store, payload) { } export async function like (store, payload) { - const url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatId}/like`; + let url = ''; + + if (payload.groupId === 'privateMessage') { + url = `/api/v4/inbox/like-private-message/${payload.chatMessageId}`; + } else { + url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatMessageId}/like`; + } + const response = await axios.post(url); return response.data.data; } diff --git a/website/client/tests/unit/components/chat/chatCard.spec.js b/website/client/tests/unit/components/messages/messageCard.spec.js similarity index 87% rename from website/client/tests/unit/components/chat/chatCard.spec.js rename to website/client/tests/unit/components/messages/messageCard.spec.js index 090521cfaef..7cd8c3672e4 100644 --- a/website/client/tests/unit/components/chat/chatCard.spec.js +++ b/website/client/tests/unit/components/messages/messageCard.spec.js @@ -1,14 +1,16 @@ import Vue from 'vue'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import ChatCard from '@/components/chat/chatCard.vue'; +import BootstrapVue from 'bootstrap-vue'; +import MessageCard from '@/components/messages/messageCard.vue'; import Store from '@/libs/store'; const localVue = createLocalVue(); localVue.use(Store); localVue.use(Vue.directive('b-tooltip', {})); +localVue.use(BootstrapVue); -describe('ChatCard', () => { +describe('MessageCard', () => { function createMessage (text) { return { text, likes: {} }; } @@ -26,7 +28,7 @@ describe('ChatCard', () => { let wrapper; beforeEach(() => { - wrapper = shallowMount(ChatCard, { + wrapper = shallowMount(MessageCard, { propsData: { msg: message }, store: new Store({ state: { diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index d5f180c58d1..0f05f492881 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -207,7 +207,8 @@ "dismissAll": "Dismiss All", "messages": "Messages", "emptyMessagesLine1": "You don't have any messages", - "emptyMessagesLine2": "You can send a new message to a user by visiting their profile and clicking the \"Message\" button.", + "emptyMessagesLine2": "Send a message to start a conversation with your Party members or another Habitica player", + "newMessage": "New Message", "userSentMessage": "<%- user %> sent you a message", "letsgo": "Let's Go!", "selected": "Selected", @@ -238,5 +239,7 @@ "submitQuestion": "Submit Question", "whyReportingPlayer": "Why are you reporting this player?", "whyReportingPlayerPlaceholder": "Reason for report", - "playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habitica’s Community Guidelines." + "playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habitica’s Community Guidelines.", + "targetUserNotExist": "Target User: '<%= userName %>' does not exist.", + "rememberToBeKind": "Please remember to be kind, respectful, and follow the Community Guidelines." } diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 60ee1db9431..2de39ea0758 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -114,9 +114,7 @@ "whyReportingPostPlaceholder": "Reason for report", "optional": "Optional", "needsTextPlaceholder": "Type your message here.", - "copyMessageAsToDo": "Copy message as To Do", - "copyAsTodo": "Copy as To Do", - "messageAddedAsToDo": "Message copied as To Do.", + "messageCopiedToClipboard": "Message copied to clipboard.", "leaderOnlyChallenges": "Only group leader can create challenges", "sendGift": "Send a Gift", "selectGift": "Select Gift", diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 86a263b1127..2359bdee8ed 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -51,8 +51,6 @@ "messageNotAbleToBuyInBulk": "This item cannot be purchased in quantities above 1.", "notificationsRequired": "Notification ids are required.", "unallocatedStatsPoints": "You have <%= points %> unallocated Stat Points", - "beginningOfConversation": "This is the beginning of your conversation with <%= userName %>.", - "beginningOfConversationReminder": "Remember to be kind, respectful, and follow the Community Guidelines!", "messageDeletedUser": "Sorry, this user has deleted their account.", "messageMissingDisplayName": "Missing display name.", "reportedMessage": "You have reported this message to moderators.", diff --git a/website/server/controllers/api-v3/inbox.js b/website/server/controllers/api-v3/inbox.js index d06a654afbf..f2bb28d09f8 100644 --- a/website/server/controllers/api-v3/inbox.js +++ b/website/server/controllers/api-v3/inbox.js @@ -1,5 +1,10 @@ import { authWithHeaders } from '../../middlewares/auth'; import * as inboxLib from '../../libs/inbox'; +import { sanitizeText as sanitizeMessageText } from '../../models/message'; +import highlightMentions from '../../libs/highlightMentions'; +import { model as User } from '../../models/user'; +import { NotAuthorized, NotFound } from '../../libs/errors'; +import { sentMessage } from '../../libs/inbox'; const api = {}; @@ -33,4 +38,44 @@ api.getInboxMessages = { }, }; +/** + * @api {post} /api/v3/members/send-private-message Send a private message to a member + * @apiName SendPrivateMessage + * @apiGroup Member + * + * @apiParam (Body) {String} message The message + * @apiParam (Body) {UUID} toUserId The id of the user to contact + * + * @apiSuccess {Object} data.message The message just sent + * + * @apiUse UserNotFound + */ +api.sendPrivateMessage = { + method: 'POST', + url: '/members/send-private-message', + middlewares: [authWithHeaders()], + async handler (req, res) { + req.checkBody('message', res.t('messageRequired')).notEmpty(); + req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const sender = res.locals.user; + const sanitizedMessageText = sanitizeMessageText(req.body.message); + const message = (await highlightMentions(sanitizedMessageText))[0]; + + const receiver = await User.findById(req.body.toUserId).exec(); + if (!receiver) throw new NotFound(res.t('userNotFound')); + if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username; + + const objections = sender.getObjectionsToInteraction('send-private-message', receiver); + if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0])); + + const messageSent = await sentMessage(sender, receiver, message, res.t); + + res.respond(200, { message: messageSent }); + }, +}; + export default api; diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 997ac57b2f3..a8fc36dcda7 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -22,11 +22,6 @@ import { } from '../../libs/email'; import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; import common from '../../../common'; -import { sentMessage } from '../../libs/inbox'; -import { - sanitizeText as sanitizeMessageText, -} from '../../models/message'; -import highlightMentions from '../../libs/highlightMentions'; import { handleGetMembersForChallenge } from '../../libs/challenges/handleGetMembersForChallenge'; import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; @@ -105,7 +100,7 @@ const api = {}; api.getMember = { method: 'GET', url: '/members/:memberId', - middlewares: [], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); @@ -134,7 +129,7 @@ api.getMember = { api.getMemberByUsername = { method: 'GET', url: '/members/username/:username', - middlewares: [], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('username', res.t('invalidReqParams')).notEmpty(); @@ -146,15 +141,25 @@ api.getMemberByUsername = { const member = await User .findOne({ 'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true }) - .select(memberFields) + .select(`${memberFields} blocks`) .exec(); if (!member) throw new NotFound(res.t('userNotFound')); + const blocksArray = member.blocks || []; + + delete member.blocks; + // manually call toJSON with minimize: true so empty paths aren't returned const memberToJSON = member.toJSON({ minimize: true }); User.addComputedStatsToJSONObj(memberToJSON.stats, member); + const { user } = res.locals; + + const isRequestingUserBlocked = blocksArray.includes(user._id); + + memberToJSON.inbox.canReceive = !(memberToJSON.inbox.optOut || isRequestingUserBlocked) || user.hasPermission('moderator'); + res.respond(200, memberToJSON); }, }; @@ -253,7 +258,7 @@ api.getMemberByUsername = { api.getMemberAchievements = { method: 'GET', url: '/members/:memberId/achievements', - middlewares: [], + middlewares: [authWithHeaders()], async handler (req, res) { req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); @@ -638,46 +643,6 @@ api.getObjectionsToInteraction = { }, }; -/** - * @api {post} /api/v3/members/send-private-message Send a private message to a member - * @apiName SendPrivateMessage - * @apiGroup Member - * - * @apiParam (Body) {String} message The message - * @apiParam (Body) {UUID} toUserId The id of the user to contact - * - * @apiSuccess {Object} data.message The message just sent - * - * @apiUse UserNotFound - */ -api.sendPrivateMessage = { - method: 'POST', - url: '/members/send-private-message', - middlewares: [authWithHeaders()], - async handler (req, res) { - req.checkBody('message', res.t('messageRequired')).notEmpty(); - req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID(); - - const validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - const sender = res.locals.user; - const sanitizedMessageText = sanitizeMessageText(req.body.message); - const message = (await highlightMentions(sanitizedMessageText))[0]; - - const receiver = await User.findById(req.body.toUserId).exec(); - if (!receiver) throw new NotFound(res.t('userNotFound')); - if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username; - - const objections = sender.getObjectionsToInteraction('send-private-message', receiver); - if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0])); - - const messageSent = await sentMessage(sender, receiver, message, res.t); - - res.respond(200, { message: messageSent }); - }, -}; - /** * @api {post} /api/v3/members/transfer-gems Send a gem gift to a member * @apiName TransferGems diff --git a/website/server/controllers/api-v4/inbox.js b/website/server/controllers/api-v4/inbox.js index 59997b82cbd..f1d8c6ffbea 100644 --- a/website/server/controllers/api-v4/inbox.js +++ b/website/server/controllers/api-v4/inbox.js @@ -1,10 +1,14 @@ import { authWithHeaders } from '../../middlewares/auth'; import { apiError } from '../../libs/apiError'; -import { - NotFound, -} from '../../libs/errors'; +import { NotFound } from '../../libs/errors'; import { listConversations } from '../../libs/inbox/conversation.methods'; -import { clearPMs, deleteMessage, getUserInbox } from '../../libs/inbox'; +import { + applyLikeToMessages, + clearPMs, deleteMessage, getUserInbox, +} from '../../libs/inbox'; +import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; +import * as inboxLib from '../../libs/inbox'; +import logger from '../../libs/logger'; const api = {}; @@ -93,6 +97,7 @@ api.clearMessages = { * {"success":true,"data":[ * { * "_id":"8a9d461b-f5eb-4a16-97d3-c03380c422a3", + * "uuid":"8a9d461b-f5eb-4a16-97d3-c03380c422a3", * "user":"user display name", * "username":"some_user_name", * "timestamp":"12315123123", @@ -147,4 +152,94 @@ api.getInboxMessages = { }, }; +/** + * @apiIgnore + * @api {post} /api/v4/members/flag-private-message/:messageId Flag a private message + * @apiDescription Moderators are notified about every flagged message, + * including the sender, recipient, and full content of the message. + * This is for API v4 which must not be used in third-party tools as it can change without notice. + * There is no equivalent route in API v3. + * @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, + }); + }, +}; + +/** + * @api {post} /api/v4//inbox/like-private-message/:uniqueMessageId Like a private message + * @apiName LikePrivateMessage + * @apiGroup Inbox + * @apiDescription Likes a private message, this uses the uniqueMessageId which is a shared ID + * between message copies of both chat participants + * + * @apiParam (Path) {UUID} uniqueMessageId This is NOT private message.id, + * but rather message.uniqueMessageId + * + * @apiSuccess {Object} data The liked private message + * + * @apiUse MessageNotFound + */ +api.likePrivateMessage = { + method: 'POST', + url: '/inbox/like-private-message/:uniqueMessageId', + middlewares: [authWithHeaders()], + async handler (req, res) { + req.checkParams('uniqueMessageId', apiError('messageIdRequired')).notEmpty(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const { user } = res.locals; + const { uniqueMessageId } = req.params; + + const messages = await inboxLib.getInboxMessagesByUniqueId(uniqueMessageId); + + if (messages.length === 0) { + throw new NotFound(res.t('messageGroupChatNotFound')); + } + + if (messages.length > 2) { + logger.error(`More than 2 Messages exist with this uniqueMessageId: ${uniqueMessageId} check in Database!`); + } + + await applyLikeToMessages(user, messages); + + const messageToReturn = messages.find(m => m.uuid === user._id); + + res.respond(200, messageToReturn); + }, +}; + export default api; diff --git a/website/server/controllers/api-v4/members.js b/website/server/controllers/api-v4/members.js index 360f0e948ec..ce045e08a3a 100644 --- a/website/server/controllers/api-v4/members.js +++ b/website/server/controllers/api-v4/members.js @@ -1,55 +1,9 @@ import { authWithHeaders } from '../../middlewares/auth'; -import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; import { ensurePermission } from '../../middlewares/ensureAccessRight'; import { TransactionModel as Transaction } from '../../models/transaction'; const api = {}; -/** - * @apiIgnore - * @api {post} /api/v4/members/flag-private-message/:messageId Flag a private message - * @apiDescription Moderators are notified about every flagged message, - * including the sender, recipient, and full content of the message. - * This is for API v4 which must not be used in third-party tools as it can change without notice. - * There is no equivalent route in API v3. - * @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, - }); - }, -}; - /** * @api {get} /api/v4/user/purchase-history Get users purchase history * @apiName UserGetPurchaseHistory diff --git a/website/server/libs/apiError.js b/website/server/libs/apiError.js index 1832a248a54..44117aa1a53 100644 --- a/website/server/libs/apiError.js +++ b/website/server/libs/apiError.js @@ -8,7 +8,7 @@ import common from '../../common'; const commonErrors = common.errorMessages.common; const apiErrors = common.errorMessages.api; -function apiError (msgKey, vars = {}) { +export function apiError (msgKey, vars = {}) { let message = apiErrors[msgKey]; if (!message) message = commonErrors[msgKey]; if (!message) throw new Error(`Error processing the API message "${msgKey}".`); @@ -18,7 +18,3 @@ function apiError (msgKey, vars = {}) { // TODO cache the result of template() ? More memory usage, faster output return _.template(message)(clonedVars); } - -export { - apiError, -}; diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js index f5a36821269..1117c7cb47e 100644 --- a/website/server/libs/inbox/index.js +++ b/website/server/libs/inbox/index.js @@ -1,6 +1,6 @@ -import { mapInboxMessage, inboxModel as Inbox } from '../../models/message'; +import { mapInboxMessage, inboxModel } from '../../models/message'; import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disable-line import/no-cycle -import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle +import { sendNotification as sendPushNotification } from '../pushNotifications'; export async function sentMessage (sender, receiver, message, translate) { const messageSent = await sender.sendMessage(receiver, { receiverMsg: message }); @@ -50,7 +50,7 @@ export async function getUserInbox (user, optionParams = getUserInboxDefaultOpti findObj.uuid = options.conversation; } - let query = Inbox + let query = inboxModel .find(findObj) .sort({ timestamp: -1 }); @@ -81,14 +81,50 @@ export async function getUserInbox (user, optionParams = getUserInboxDefaultOpti return messagesObj; } +export async function applyLikeToMessages (user, uniqueMessages) { + const bulkWriteOperations = []; + + for (const message of uniqueMessages) { + if (!message.likes) { + message.likes = {}; + } + + message.likes[user._id] = !message.likes[user._id]; + + bulkWriteOperations.push({ + updateOne: { + filter: { _id: message._id }, + update: { + $set: { + likes: message.likes, + }, + }, + }, + }); + } + + await inboxModel.bulkWrite(bulkWriteOperations, {}); +} + +export async function getInboxMessagesByUniqueId (uniqueMessageId) { + return inboxModel + .find({ uniqueMessageId }) + // prevents creating the proxies, no .save() and other stuff + .lean() + // since there can be only 2 messages maximum for this uniqueMessageId, + // this might speed up the query + .limit(2) + .exec(); +} + export async function getUserInboxMessage (user, messageId) { - return Inbox.findOne({ ownerId: user._id, _id: messageId }).exec(); + return inboxModel.findOne({ ownerId: user._id, _id: messageId }).exec(); } export async function deleteMessage (user, messageId) { - const message = await Inbox.findOne({ _id: messageId, ownerId: user._id }).exec(); + const message = await inboxModel.findOne({ _id: messageId, ownerId: user._id }).exec(); if (!message) return false; - await Inbox.deleteOne({ _id: message._id, ownerId: user._id }).exec(); + await inboxModel.deleteOne({ _id: message._id, ownerId: user._id }).exec(); return true; } @@ -98,6 +134,6 @@ export async function clearPMs (user) { await Promise.all([ user.save(), - Inbox.deleteMany({ ownerId: user._id }).exec(), + inboxModel.deleteMany({ ownerId: user._id }).exec(), ]); } diff --git a/website/server/models/message.js b/website/server/models/message.js index f8ae3a1df4c..4f136791fac 100644 --- a/website/server/models/message.js +++ b/website/server/models/message.js @@ -45,6 +45,7 @@ const inboxSchema = new mongoose.Schema({ // we store two copies of each inbox messages: // one for the sender and one for the receiver ownerId: { $type: String, ref: 'User' }, + uniqueMessageId: String, ...defaultSchema(), }, { minimize: false, // Allow for empty flags to be saved diff --git a/website/server/models/typedefs.d.ts b/website/server/models/typedefs.d.ts new file mode 100644 index 00000000000..43949052655 --- /dev/null +++ b/website/server/models/typedefs.d.ts @@ -0,0 +1,11 @@ +// This file is just to improve the dev-experiences when working with untyped js classes +// These Types don't have to have all properties (for now) but should be extended once the +// type is used in any new method + +interface InboxMessage { + id: string; + uniqueMessageId: string; + likes: { + [userId: string]: boolean + } +} diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index e2326a73114..d9f478a932c 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -2,6 +2,7 @@ import moment from 'moment'; import { defaults, map, flatten, flow, compact, uniq, partialRight, remove, } from 'lodash'; +import { v4 as uuid } from 'uuid'; import common from '../../../common'; import { // eslint-disable-line import/no-cycle @@ -125,8 +126,11 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o // whether to save users after sending the message, defaults to true const saveUsers = options.save !== false; + const uniqueMessageId = uuid(); + const newReceiverMessage = new Inbox({ ownerId: userToReceiveMessage._id, + uniqueMessageId, }); Object.assign(newReceiverMessage, messageDefaults(options.receiverMsg, sender)); setUserStyles(newReceiverMessage, sender); @@ -165,6 +169,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o newSenderMessage = new Inbox({ sent: true, ownerId: sender._id, + uniqueMessageId, }); Object.assign(newSenderMessage, messageDefaults(senderMsg, userToReceiveMessage)); setUserStyles(newSenderMessage, sender);