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 @@
-
-
-
- @{{ msg.username }}•
- {{ msg.timestamp | timeAgo }}
- ({{ msg.client }})
-
+
-
- {{ $t('reportedMessage') }}
- {{ $t('canDeleteNow') }}
-
-
-
+
-
-
- {{ $t('report') }}
-
+ {{ flagCountDescription }}
+
+
+ @{{ msg.username }}•
+ {{ msg.timestamp | timeAgo }}
+
+ ({{ msg.client }})
+
+
+
+
+
+ {{ msg.unformattedText }}
+
+
-
- {{ $t('delete') }}
+
+ {{ $t('reportedMessage') }}
+ {{ $t('canDeleteNow') }}
+
+
@@ -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('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 @@
+
+
+
{{ disabledTexts.title }}
+
{{ disabledTexts.description }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
{{ memberObj.profile.name }}
+
+ @{{ memberObj.auth.local.username }}
+
+
+
+
+
+
+
+
+
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);