mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 05:37:22 +01:00
Performance: Inbox Paging / loading (#11157)
* load messages per conversation * only sort ones in ui * add contributor to message * fix correct message layout/message * mugenScroll on chatMessages * fix lint, no mugen-scroll, use own scroll handler * fix height / margin of modal + use button to load more * fix tests * user data from inbox * style "load earlier messages" * move mapMessage to the inbox api result / extract sentMessage of members-api-controller * fix test back * fix test * keep last scroll position * just set the Id of the returned message instead of all other properties * fix add new messages (buttons were hidden) + load more * item-mounted debounce to trigger the re-scrolling
This commit is contained in:
@@ -6,7 +6,7 @@ describe('GET /inbox/messages', () => {
|
|||||||
let user;
|
let user;
|
||||||
let otherUser;
|
let otherUser;
|
||||||
|
|
||||||
before(async () => {
|
beforeEach(async () => {
|
||||||
[user, otherUser] = await Promise.all([generateUser(), generateUser()]);
|
[user, otherUser] = await Promise.all([generateUser(), generateUser()]);
|
||||||
|
|
||||||
await otherUser.post('/members/send-private-message', {
|
await otherUser.post('/members/send-private-message', {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe('GET /inbox/conversations', () => {
|
|||||||
let otherUser;
|
let otherUser;
|
||||||
let thirdUser;
|
let thirdUser;
|
||||||
|
|
||||||
before(async () => {
|
beforeEach(async () => {
|
||||||
[user, otherUser, thirdUser] = await Promise.all([generateUser(), generateUser(), generateUser()]);
|
[user, otherUser, thirdUser] = await Promise.all([generateUser(), generateUser(), generateUser()]);
|
||||||
|
|
||||||
await otherUser.post('/members/send-private-message', {
|
await otherUser.post('/members/send-private-message', {
|
||||||
@@ -41,4 +41,51 @@ describe('GET /inbox/conversations', () => {
|
|||||||
expect(result[0].user).to.be.equal(user.profile.name);
|
expect(result[0].user).to.be.equal(user.profile.name);
|
||||||
expect(result[0].username).to.be.equal(user.auth.local.username);
|
expect(result[0].username).to.be.equal(user.auth.local.username);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the user inbox messages as an array of ordered messages (from most to least recent)', async () => {
|
||||||
|
const messages = await user.get('/inbox/messages');
|
||||||
|
|
||||||
|
expect(messages.length).to.equal(5);
|
||||||
|
|
||||||
|
// message to yourself
|
||||||
|
expect(messages[0].text).to.equal('fifth');
|
||||||
|
expect(messages[0].sent).to.equal(false);
|
||||||
|
expect(messages[0].uuid).to.equal(user._id);
|
||||||
|
|
||||||
|
expect(messages[1].text).to.equal('fourth');
|
||||||
|
expect(messages[2].text).to.equal('third');
|
||||||
|
expect(messages[3].text).to.equal('second');
|
||||||
|
expect(messages[4].text).to.equal('first');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns four messages when using page-query ', async () => {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
promises.push(user.post('/members/send-private-message', {
|
||||||
|
toUserId: user.id,
|
||||||
|
message: 'fourth',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const messages = await user.get('/inbox/messages?page=1');
|
||||||
|
|
||||||
|
expect(messages.length).to.equal(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the messages of one conversation', async () => {
|
||||||
|
const messages = await user.get(`/inbox/messages?conversation=${otherUser.id}`);
|
||||||
|
|
||||||
|
expect(messages.length).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the correct message format', async () => {
|
||||||
|
const messages = await otherUser.get(`/inbox/messages?conversation=${user.id}`);
|
||||||
|
|
||||||
|
expect(messages[0].toUUID).to.equal(user.id); // from user
|
||||||
|
expect(messages[1].toUUID).to.not.exist; // only filled if its from the chat partner
|
||||||
|
expect(messages[2].toUUID).to.equal(user.id); // from user
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('POST /members/flag-private-message/:messageId', () => {
|
|||||||
let senderMessages = await userToSendMessage.get('/inbox/messages');
|
let senderMessages = await userToSendMessage.get('/inbox/messages');
|
||||||
|
|
||||||
let sendersMessageInSendersInbox = _.find(senderMessages, (message) => {
|
let sendersMessageInSendersInbox = _.find(senderMessages, (message) => {
|
||||||
return message.uuid === receiver._id && message.text === messageToSend;
|
return message.toUUID === receiver._id && message.text === messageToSend;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendersMessageInSendersInbox).to.exist;
|
expect(sendersMessageInSendersInbox).to.exist;
|
||||||
|
|||||||
@@ -273,5 +273,8 @@ export default {
|
|||||||
return habiticaMarkdown.render(String(text));
|
return habiticaMarkdown.render(String(text));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mounted () {
|
||||||
|
this.$emit('item-mounted', this.msg.id);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
.container-fluid
|
.container-fluid(ref="container")
|
||||||
.row
|
.row
|
||||||
.col-12
|
.col-12
|
||||||
copy-as-todo-modal(:group-type='groupType', :group-name='groupName', :group-id='groupId')
|
copy-as-todo-modal(:group-type='groupType', :group-name='groupName', :group-id='groupId')
|
||||||
|
.row.loadmore
|
||||||
|
div(v-if="canLoadMore")
|
||||||
|
.loadmore-divider
|
||||||
|
button.btn.btn-secondary(@click='triggerLoad()') {{ $t('loadEarlierMessages') }}
|
||||||
|
.loadmore-divider
|
||||||
|
h2.col-12.loading(v-show="isLoading") {{ $t('loading') }}
|
||||||
div(v-for="(msg, index) in messages", v-if='chat && canViewFlag(msg)', :class='{row: inbox}')
|
div(v-for="(msg, index) in messages", v-if='chat && canViewFlag(msg)', :class='{row: inbox}')
|
||||||
.d-flex(v-if='user._id !== msg.uuid', :class='{"flex-grow-1": inbox}')
|
.d-flex(v-if='user._id !== msg.uuid', :class='{"flex-grow-1": inbox}')
|
||||||
avatar.avatar-left(
|
avatar.avatar-left(
|
||||||
@@ -21,7 +27,8 @@
|
|||||||
:groupId='groupId',
|
:groupId='groupId',
|
||||||
@message-liked='messageLiked',
|
@message-liked='messageLiked',
|
||||||
@message-removed='messageRemoved',
|
@message-removed='messageRemoved',
|
||||||
@show-member-modal='showMemberModal')
|
@show-member-modal='showMemberModal',
|
||||||
|
@item-mounted='itemWasMounted')
|
||||||
.d-flex(v-if='user._id === msg.uuid', :class='{"flex-grow-1": inbox}')
|
.d-flex(v-if='user._id === msg.uuid', :class='{"flex-grow-1": inbox}')
|
||||||
.card(:class='{"col-10": inbox}')
|
.card(:class='{"col-10": inbox}')
|
||||||
chat-card(
|
chat-card(
|
||||||
@@ -30,7 +37,8 @@
|
|||||||
:groupId='groupId',
|
:groupId='groupId',
|
||||||
@message-liked='messageLiked',
|
@message-liked='messageLiked',
|
||||||
@message-removed='messageRemoved',
|
@message-removed='messageRemoved',
|
||||||
@show-member-modal='showMemberModal')
|
@show-member-modal='showMemberModal',
|
||||||
|
@item-mounted='itemWasMounted')
|
||||||
avatar(
|
avatar(
|
||||||
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
|
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
|
||||||
:member="msg.userStyles || cachedProfileData[msg.uuid]",
|
:member="msg.userStyles || cachedProfileData[msg.uuid]",
|
||||||
@@ -49,6 +57,34 @@
|
|||||||
width: 10%;
|
width: 10%;
|
||||||
min-width: 7rem;
|
min-width: 7rem;
|
||||||
}
|
}
|
||||||
|
.loadmore {
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-50;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadmore-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: $gray-500;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 24px;
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-left {
|
.avatar-left {
|
||||||
margin-left: -1.5rem;
|
margin-left: -1.5rem;
|
||||||
@@ -97,6 +133,8 @@
|
|||||||
.message-scroll .d-flex {
|
.message-scroll .d-flex {
|
||||||
min-width: 1px;
|
min-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -120,6 +158,9 @@ export default {
|
|||||||
groupType: {},
|
groupType: {},
|
||||||
groupId: {},
|
groupId: {},
|
||||||
groupName: {},
|
groupName: {},
|
||||||
|
|
||||||
|
isLoading: Boolean,
|
||||||
|
canLoadMore: Boolean,
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
copyAsTodoModal,
|
copyAsTodoModal,
|
||||||
@@ -142,6 +183,8 @@ export default {
|
|||||||
currentProfileLoadedCount: 0,
|
currentProfileLoadedCount: 0,
|
||||||
currentProfileLoadedEnd: 10,
|
currentProfileLoadedEnd: 10,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
handleScrollBack: false,
|
||||||
|
lastOffset: -1,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -153,15 +196,24 @@ export default {
|
|||||||
return this.chat;
|
return this.chat;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
messages () {
|
|
||||||
this.loadProfileCache();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
handleScroll () {
|
handleScroll () {
|
||||||
this.loadProfileCache(window.scrollY / 1000);
|
this.loadProfileCache(window.scrollY / 1000);
|
||||||
},
|
},
|
||||||
|
async triggerLoad () {
|
||||||
|
const container = this.$refs.container;
|
||||||
|
|
||||||
|
// get current offset
|
||||||
|
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
|
||||||
|
// disable scroll
|
||||||
|
container.style.overflowY = 'hidden';
|
||||||
|
|
||||||
|
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
|
||||||
|
if (canLoadMore) {
|
||||||
|
await this.$emit('triggerLoad');
|
||||||
|
this.handleScrollBack = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
canViewFlag (message) {
|
canViewFlag (message) {
|
||||||
if (message.uuid === this.user._id) return true;
|
if (message.uuid === this.user._id) return true;
|
||||||
if (!message.flagCount || message.flagCount < 2) return true;
|
if (!message.flagCount || message.flagCount < 2) return true;
|
||||||
@@ -252,6 +304,20 @@ export default {
|
|||||||
this.$router.push({name: 'userProfile', params: {userId: profile._id}});
|
this.$router.push({name: 'userProfile', params: {userId: profile._id}});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
itemWasMounted: debounce(function itemWasMounted () {
|
||||||
|
if (this.handleScrollBack) {
|
||||||
|
this.handleScrollBack = false;
|
||||||
|
|
||||||
|
const container = this.$refs.container;
|
||||||
|
const offset = container.scrollHeight - container.clientHeight;
|
||||||
|
|
||||||
|
const newOffset = offset + this.lastOffset;
|
||||||
|
|
||||||
|
container.scrollTo(0, newOffset);
|
||||||
|
// enable scroll again
|
||||||
|
container.style.overflowY = 'scroll';
|
||||||
|
}
|
||||||
|
}, 50),
|
||||||
messageLiked (message) {
|
messageLiked (message) {
|
||||||
const chatIndex = findIndex(this.chat, chatMessage => {
|
const chatIndex = findIndex(this.chat, chatMessage => {
|
||||||
return chatMessage.id === message.id;
|
return chatMessage.id === message.id;
|
||||||
|
|||||||
@@ -34,21 +34,25 @@
|
|||||||
.svg-icon(v-html="tierIcon(conversation)")
|
.svg-icon(v-html="tierIcon(conversation)")
|
||||||
.time
|
.time
|
||||||
span.mr-1(v-if='conversation.username') @{{ conversation.username }} •
|
span.mr-1(v-if='conversation.username') @{{ conversation.username }} •
|
||||||
span {{ conversation.date | timeAgo }}
|
span(v-if="conversation.date") {{ conversation.date | timeAgo }}
|
||||||
div.messagePreview {{ conversation.lastMessageText ? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
|
div.messagePreview {{ conversation.lastMessageText ? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
|
||||||
.col-8.messages.d-flex.flex-column.justify-content-between
|
.col-8.messages.d-flex.flex-column.justify-content-between
|
||||||
.empty-messages.text-center(v-if='!selectedConversation.key')
|
.empty-messages.text-center(v-if='!selectedConversation.key')
|
||||||
.svg-icon.envelope(v-html="icons.messageIcon")
|
.svg-icon.envelope(v-html="icons.messageIcon")
|
||||||
h4 {{placeholderTexts.title}}
|
h4 {{placeholderTexts.title}}
|
||||||
p(v-html="placeholderTexts.description")
|
p(v-html="placeholderTexts.description")
|
||||||
.empty-messages.text-center(v-if='selectedConversation.key && selectedConversationMessages.length === 0')
|
.empty-messages.text-center(v-if='selectedConversation && selectedConversationMessages.length === 0')
|
||||||
p {{ $t('beginningOfConversation', {userName: selectedConversation.name})}}
|
p {{ $t('beginningOfConversation', {userName: selectedConversation.name})}}
|
||||||
chat-messages.message-scroll(
|
chat-messages.message-scroll(
|
||||||
v-if="selectedConversation.messages && selectedConversationMessages.length > 0",
|
v-if="selectedConversation && selectedConversationMessages.length > 0",
|
||||||
:chat='selectedConversationMessages',
|
:chat='selectedConversationMessages',
|
||||||
:inbox='true',
|
:inbox='true',
|
||||||
@message-removed='messageRemoved',
|
@message-removed='messageRemoved',
|
||||||
ref="chatscroll"
|
ref="chatscroll",
|
||||||
|
|
||||||
|
:canLoadMore="canLoadMore",
|
||||||
|
:isLoading="messagesLoading",
|
||||||
|
@triggerLoad="infiniteScrollTrigger"
|
||||||
)
|
)
|
||||||
.pm-disabled-caption.text-center(v-if="user.inbox.optOut && selectedConversation.key")
|
.pm-disabled-caption.text-center(v-if="user.inbox.optOut && selectedConversation.key")
|
||||||
h4 {{$t('PMDisabledCaptionTitle')}}
|
h4 {{$t('PMDisabledCaptionTitle')}}
|
||||||
@@ -64,6 +68,12 @@
|
|||||||
span.ml-3 {{ currentLength }} / 3000
|
span.ml-3 {{ currentLength }} / 3000
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#inbox-modal .modal-body {
|
||||||
|
padding-top: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import '~client/assets/scss/colors.scss';
|
@import '~client/assets/scss/colors.scss';
|
||||||
@import '~client/assets/scss/tiers.scss';
|
@import '~client/assets/scss/tiers.scss';
|
||||||
@@ -94,7 +104,7 @@
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background-color: $gray-700;
|
background-color: $gray-700;
|
||||||
min-height: 600px;
|
min-height: 540px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
.search-section {
|
.search-section {
|
||||||
@@ -107,6 +117,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-bottom: 6em;
|
padding-bottom: 6em;
|
||||||
|
height: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-scroll {
|
.message-scroll {
|
||||||
@@ -225,8 +236,8 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import filter from 'lodash/filter';
|
import filter from 'lodash/filter';
|
||||||
import sortBy from 'lodash/sortBy';
|
|
||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
|
import orderBy from 'lodash/orderBy';
|
||||||
import { mapState } from 'client/libs/store';
|
import { mapState } from 'client/libs/store';
|
||||||
import habiticaMarkdown from 'habitica-markdown';
|
import habiticaMarkdown from 'habitica-markdown';
|
||||||
import styleHelper from 'client/mixins/styleHelper';
|
import styleHelper from 'client/mixins/styleHelper';
|
||||||
@@ -308,8 +319,14 @@ export default {
|
|||||||
newMessage: '',
|
newMessage: '',
|
||||||
showPopover: false,
|
showPopover: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesByConversation: {}, // cache {uuid: []}
|
||||||
|
loadedConversations: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
messagesLoading: false,
|
||||||
|
canLoadMore: true,
|
||||||
|
page: 0,
|
||||||
initiatedConversation: null,
|
initiatedConversation: null,
|
||||||
|
updateConversionsCounter: 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -320,7 +337,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState({user: 'user.data'}),
|
...mapState({user: 'user.data'}),
|
||||||
conversations () {
|
conversations () {
|
||||||
const inboxGroup = groupBy(this.messages, 'uuid');
|
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
|
||||||
|
|
||||||
// Add placeholder for new conversations
|
// Add placeholder for new conversations
|
||||||
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
||||||
@@ -328,6 +345,7 @@ export default {
|
|||||||
uuid: this.initiatedConversation.uuid,
|
uuid: this.initiatedConversation.uuid,
|
||||||
user: this.initiatedConversation.user,
|
user: this.initiatedConversation.user,
|
||||||
username: this.initiatedConversation.username,
|
username: this.initiatedConversation.username,
|
||||||
|
contributor: this.initiatedConversation.contributor,
|
||||||
id: '',
|
id: '',
|
||||||
text: '',
|
text: '',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -336,31 +354,7 @@ export default {
|
|||||||
// Create conversation objects
|
// Create conversation objects
|
||||||
const convos = [];
|
const convos = [];
|
||||||
for (let key in inboxGroup) {
|
for (let key in inboxGroup) {
|
||||||
const convoSorted = sortBy(inboxGroup[key], [(o) => {
|
const recentMessage = inboxGroup[key][0];
|
||||||
return (new Date(o.timestamp)).getTime();
|
|
||||||
}]);
|
|
||||||
|
|
||||||
// Fix poor inbox chat models
|
|
||||||
const newChatModels = convoSorted.map(chat => {
|
|
||||||
let newChat = Object.assign({}, chat);
|
|
||||||
if (newChat.sent) {
|
|
||||||
newChat.toUUID = newChat.uuid;
|
|
||||||
newChat.toUser = newChat.user;
|
|
||||||
newChat.toUserName = newChat.username;
|
|
||||||
newChat.toUserContributor = newChat.contributor;
|
|
||||||
newChat.toUserBacker = newChat.backer;
|
|
||||||
newChat.uuid = this.user._id;
|
|
||||||
newChat.user = this.user.profile.name;
|
|
||||||
newChat.username = this.user.auth.local.username;
|
|
||||||
newChat.contributor = this.user.contributor;
|
|
||||||
newChat.backer = this.user.backer;
|
|
||||||
}
|
|
||||||
return newChat;
|
|
||||||
});
|
|
||||||
|
|
||||||
// In case the last message is a placeholder, remove it
|
|
||||||
const recentMessage = newChatModels[newChatModels.length - 1];
|
|
||||||
if (!recentMessage.text) newChatModels.splice(newChatModels.length - 1, 1);
|
|
||||||
|
|
||||||
const convoModel = {
|
const convoModel = {
|
||||||
key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid,
|
key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid,
|
||||||
@@ -368,30 +362,46 @@ export default {
|
|||||||
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
|
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
|
||||||
date: recentMessage.timestamp,
|
date: recentMessage.timestamp,
|
||||||
lastMessageText: recentMessage.text,
|
lastMessageText: recentMessage.text,
|
||||||
messages: newChatModels,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
convos.push(convoModel);
|
convos.push(convoModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort models by most recent
|
return convos;
|
||||||
const conversations = sortBy(convos, [(o) => {
|
|
||||||
return moment(o.date).toDate();
|
|
||||||
}]);
|
|
||||||
|
|
||||||
return conversations.reverse();
|
|
||||||
},
|
},
|
||||||
// Separate from selectedConversation which is not coputed so messages don't update automatically
|
// Separate from selectedConversation which is not computed so messages don't update automatically
|
||||||
selectedConversationMessages () {
|
selectedConversationMessages () {
|
||||||
|
// Vue-subscribe to changes
|
||||||
|
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
|
||||||
|
|
||||||
|
|
||||||
const selectedConversationKey = this.selectedConversation.key;
|
const selectedConversationKey = this.selectedConversation.key;
|
||||||
const selectedConversation = this.conversations.find(c => c.key === selectedConversationKey);
|
const selectedConversation = this.messagesByConversation[selectedConversationKey];
|
||||||
return selectedConversation ? selectedConversation.messages : [];
|
this.messages = selectedConversation || [];
|
||||||
|
|
||||||
|
const ordered = orderBy(this.messages, [(m) => {
|
||||||
|
return m.timestamp;
|
||||||
|
}], ['asc']);
|
||||||
|
|
||||||
|
if (subScribeToUpdate) {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
filtersConversations () {
|
filtersConversations () {
|
||||||
if (!this.search) return this.conversations;
|
// Vue-subscribe to changes
|
||||||
return filter(this.conversations, (conversation) => {
|
const subScribeToUpdate = this.updateConversionsCounter > -1;
|
||||||
return conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1;
|
|
||||||
});
|
const filtered = subScribeToUpdate && !this.search ?
|
||||||
|
this.conversations :
|
||||||
|
filter(this.conversations, (conversation) => {
|
||||||
|
return conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ordered = orderBy(filtered, [(o) => {
|
||||||
|
return moment(o.date).toDate();
|
||||||
|
}], ['desc']);
|
||||||
|
|
||||||
|
return ordered;
|
||||||
},
|
},
|
||||||
currentLength () {
|
currentLength () {
|
||||||
return this.newMessage.length;
|
return this.newMessage.length;
|
||||||
@@ -424,25 +434,34 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async onModalShown () {
|
async onModalShown () {
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
const res = await axios.get('/api/v4/inbox/messages');
|
|
||||||
this.messages = res.data.data;
|
const conversationRes = await axios.get('/api/v4/inbox/conversations');
|
||||||
|
this.loadedConversations = conversationRes.data.data;
|
||||||
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
},
|
},
|
||||||
onModalHide () {
|
onModalHide () {
|
||||||
this.messages = [];
|
// reset everything
|
||||||
|
this.loadedConversations = [];
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
this.initiatedConversation = null;
|
this.initiatedConversation = null;
|
||||||
|
this.messagesByConversation = {};
|
||||||
|
this.selectedConversation = {};
|
||||||
},
|
},
|
||||||
messageRemoved (message) {
|
messageRemoved (message) {
|
||||||
const messageIndex = this.messages.findIndex(msg => msg.id === message.id);
|
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||||
if (messageIndex !== -1) this.messages.splice(messageIndex, 1);
|
|
||||||
if (this.selectedConversationMessages.length === 0) this.initiatedConversation = {
|
const messageIndex = messages.findIndex(msg => msg.id === message.id);
|
||||||
uuid: this.selectedConversation.key,
|
if (messageIndex !== -1) messages.splice(messageIndex, 1);
|
||||||
user: this.selectedConversation.name,
|
if (this.selectedConversationMessages.length === 0) {
|
||||||
username: this.selectedConversation.username,
|
this.initiatedConversation = {
|
||||||
backer: this.selectedConversation.backer,
|
uuid: this.selectedConversation.key,
|
||||||
contributor: this.selectedConversation.contributor,
|
user: this.selectedConversation.name,
|
||||||
};
|
username: this.selectedConversation.username,
|
||||||
|
backer: this.selectedConversation.backer,
|
||||||
|
contributor: this.selectedConversation.contributor,
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
toggleClick () {
|
toggleClick () {
|
||||||
this.displayCreate = !this.displayCreate;
|
this.displayCreate = !this.displayCreate;
|
||||||
@@ -450,12 +469,17 @@ export default {
|
|||||||
toggleOpt () {
|
toggleOpt () {
|
||||||
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
||||||
},
|
},
|
||||||
selectConversation (key) {
|
async selectConversation (key) {
|
||||||
let convoFound = this.conversations.find((conversation) => {
|
let convoFound = this.conversations.find((conversation) => {
|
||||||
return conversation.key === key;
|
return conversation.key === key;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.selectedConversation = convoFound || {};
|
this.selectedConversation = convoFound || {};
|
||||||
|
this.page = 0;
|
||||||
|
|
||||||
|
if (!this.messagesByConversation[this.selectedConversation.key]) {
|
||||||
|
await this.loadMessages();
|
||||||
|
}
|
||||||
|
|
||||||
Vue.nextTick(() => {
|
Vue.nextTick(() => {
|
||||||
if (!this.$refs.chatscroll) return;
|
if (!this.$refs.chatscroll) return;
|
||||||
@@ -466,18 +490,31 @@ export default {
|
|||||||
sendPrivateMessage () {
|
sendPrivateMessage () {
|
||||||
if (!this.newMessage) return;
|
if (!this.newMessage) return;
|
||||||
|
|
||||||
this.messages.push({
|
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||||
|
|
||||||
|
messages.push({
|
||||||
sent: true,
|
sent: true,
|
||||||
text: this.newMessage,
|
text: this.newMessage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: this.selectedConversation.name,
|
toUser: this.selectedConversation.name,
|
||||||
username: this.selectedConversation.username,
|
toUserName: this.selectedConversation.username,
|
||||||
uuid: this.selectedConversation.key,
|
toUserContributor: this.selectedConversation.contributor,
|
||||||
|
toUserBacker: this.selectedConversation.backer,
|
||||||
|
toUUID: this.selectedConversation.uuid,
|
||||||
|
|
||||||
|
id: '-1', // will be updated once the result is back
|
||||||
|
likes: {},
|
||||||
|
ownerId: this.user._id,
|
||||||
|
uuid: this.user._id,
|
||||||
|
user: this.user.profile.name,
|
||||||
|
username: this.user.auth.local.username,
|
||||||
contributor: this.user.contributor,
|
contributor: this.user.contributor,
|
||||||
|
backer: this.user.backer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove the placeholder message
|
// Remove the placeholder message
|
||||||
if (this.initiatedConversation && this.initiatedConversation.uuid === this.selectedConversation.key) {
|
if (this.initiatedConversation && this.initiatedConversation.uuid === this.selectedConversation.key) {
|
||||||
|
this.loadedConversations.unshift(this.initiatedConversation);
|
||||||
this.initiatedConversation = null;
|
this.initiatedConversation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +532,10 @@ export default {
|
|||||||
message: this.newMessage,
|
message: this.newMessage,
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
const newMessage = response.data.data.message;
|
const newMessage = response.data.data.message;
|
||||||
Object.assign(this.messages[this.messages.length - 1], newMessage);
|
const messageToReset = messages[messages.length - 1];
|
||||||
|
messageToReset.id = newMessage.id; // just set the id, all other infos already set
|
||||||
|
Object.assign(messages[messages.length - 1], messageToReset);
|
||||||
|
this.updateConversionsCounter++;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.newMessage = '';
|
this.newMessage = '';
|
||||||
@@ -511,6 +551,34 @@ export default {
|
|||||||
if (!message.contributor) return;
|
if (!message.contributor) return;
|
||||||
return this.icons[`tier${message.contributor.level}`];
|
return this.icons[`tier${message.contributor.level}`];
|
||||||
},
|
},
|
||||||
|
infiniteScrollTrigger () {
|
||||||
|
// show loading and wait until the loadMore debounced
|
||||||
|
// or else it would trigger on every scrolling-pixel (while not loading)
|
||||||
|
if (this.canLoadMore) {
|
||||||
|
this.messagesLoading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadMore();
|
||||||
|
},
|
||||||
|
loadMore () {
|
||||||
|
this.page += 1;
|
||||||
|
return this.loadMessages();
|
||||||
|
},
|
||||||
|
async loadMessages () {
|
||||||
|
this.messagesLoading = true;
|
||||||
|
|
||||||
|
const requestUrl = `/api/v4/inbox/messages?conversation=${this.selectedConversation.key}&page=${this.page}`;
|
||||||
|
const res = await axios.get(requestUrl);
|
||||||
|
const loadedMessages = res.data.data;
|
||||||
|
|
||||||
|
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || [];
|
||||||
|
const loadedMessagesToAdd = loadedMessages.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1);
|
||||||
|
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
|
||||||
|
|
||||||
|
// only show the load more Button if the max count was returned
|
||||||
|
this.canLoadMore = loadedMessages.length === 10;
|
||||||
|
this.messagesLoading = false;
|
||||||
|
},
|
||||||
removeTags (html) {
|
removeTags (html) {
|
||||||
let tmp = document.createElement('DIV');
|
let tmp = document.createElement('DIV');
|
||||||
tmp.innerHTML = html;
|
tmp.innerHTML = html;
|
||||||
|
|||||||
@@ -298,5 +298,6 @@
|
|||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"howManyToBuy": "How many would you like to buy?",
|
"howManyToBuy": "How many would you like to buy?",
|
||||||
"habiticaHasUpdated": "There is a new Habitica update. Refresh to get the latest version!",
|
"habiticaHasUpdated": "There is a new Habitica update. Refresh to get the latest version!",
|
||||||
"contactForm": "Contact the Moderation Team"
|
"contactForm": "Contact the Moderation Team",
|
||||||
|
"loadEarlierMessages": "Load Earlier Messages"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../../libs/email';
|
} from '../../libs/email';
|
||||||
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
|
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
|
||||||
import { achievements } from '../../../../website/common/';
|
import { achievements } from '../../../../website/common/';
|
||||||
|
import {sentMessage} from '../../libs/inbox';
|
||||||
|
|
||||||
let api = {};
|
let api = {};
|
||||||
|
|
||||||
@@ -633,6 +634,7 @@ api.sendPrivateMessage = {
|
|||||||
|
|
||||||
const sender = res.locals.user;
|
const sender = res.locals.user;
|
||||||
const message = req.body.message;
|
const message = req.body.message;
|
||||||
|
|
||||||
const receiver = await User.findById(req.body.toUserId).exec();
|
const receiver = await User.findById(req.body.toUserId).exec();
|
||||||
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
||||||
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
|
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
|
||||||
@@ -640,26 +642,7 @@ api.sendPrivateMessage = {
|
|||||||
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
||||||
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
|
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
|
||||||
|
|
||||||
const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
|
const messageSent = await sentMessage(sender, receiver, message, res.t);
|
||||||
|
|
||||||
if (receiver.preferences.emailNotifications.newPM !== false) {
|
|
||||||
sendTxnEmail(receiver, 'new-pm', [
|
|
||||||
{name: 'SENDER', content: getUserInfo(sender, ['name']).name},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receiver.preferences.pushNotifications.newPM !== false) {
|
|
||||||
sendPushNotification(
|
|
||||||
receiver,
|
|
||||||
{
|
|
||||||
title: res.t('newPM'),
|
|
||||||
message: res.t('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
|
|
||||||
identifier: 'newPM',
|
|
||||||
category: 'newPM',
|
|
||||||
payload: {replyTo: sender._id},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.respond(200, {message: messageSent});
|
res.respond(200, {message: messageSent});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ api.clearMessages = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api {get} /inbox/conversations Get the conversations for a user
|
* @api {get} /api/v4/inbox/conversations Get the conversations for a user
|
||||||
* @apiName conversations
|
* @apiName conversations
|
||||||
* @apiGroup Inbox
|
* @apiGroup Inbox
|
||||||
* @apiDescription Get the conversations for a user
|
* @apiDescription Get the conversations for a user
|
||||||
@@ -93,4 +93,49 @@ api.conversations = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapMessage (newChat, user) {
|
||||||
|
if (newChat.sent) {
|
||||||
|
newChat.toUUID = newChat.uuid;
|
||||||
|
newChat.toUser = newChat.user;
|
||||||
|
newChat.toUserName = newChat.username;
|
||||||
|
newChat.toUserContributor = newChat.contributor;
|
||||||
|
newChat.toUserBacker = newChat.backer;
|
||||||
|
newChat.uuid = user._id;
|
||||||
|
newChat.user = user.profile.name;
|
||||||
|
newChat.username = user.auth.local.username;
|
||||||
|
newChat.contributor = user.contributor;
|
||||||
|
newChat.backer = user.backer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newChat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} /api/v4/inbox/messages Get inbox messages for a user
|
||||||
|
* @apiName GetInboxMessages
|
||||||
|
* @apiGroup Inbox
|
||||||
|
* @apiDescription Get inbox messages for a user. Entries already populated with the correct `sent` - information
|
||||||
|
*
|
||||||
|
* @apiParam (Query) {Number} page Load the messages of the selected Page - 10 Messages per Page
|
||||||
|
* @apiParam (Query) {GUID} conversation Loads only the messages of a conversation
|
||||||
|
*
|
||||||
|
* @apiSuccess {Array} data An array of inbox messages
|
||||||
|
*/
|
||||||
|
api.getInboxMessages = {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/inbox/messages',
|
||||||
|
middlewares: [authWithHeaders()],
|
||||||
|
async handler (req, res) {
|
||||||
|
const user = res.locals.user;
|
||||||
|
const page = req.query.page;
|
||||||
|
const conversation = req.query.conversation;
|
||||||
|
|
||||||
|
const userInbox = (await inboxLib.getUserInbox(user, {
|
||||||
|
page, conversation,
|
||||||
|
})).map(newChat => mapMessage(newChat, user));
|
||||||
|
|
||||||
|
res.respond(200, userInbox);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = api;
|
module.exports = api;
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import {inboxModel as Inbox} from '../../models/message';
|
import {inboxModel as Inbox} from '../../models/message';
|
||||||
import {
|
|
||||||
model as User,
|
|
||||||
} from '../../models/user';
|
|
||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
import keyBy from 'lodash/keyBy';
|
import {getUserInfo, sendTxn as sendTxnEmail} from '../email';
|
||||||
|
import {sendNotification as sendPushNotification} from '../pushNotifications';
|
||||||
|
|
||||||
const PM_PER_PAGE = 10;
|
const PM_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export async function sentMessage (sender, receiver, message, translate) {
|
||||||
|
const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
|
||||||
|
|
||||||
|
if (receiver.preferences.emailNotifications.newPM !== false) {
|
||||||
|
sendTxnEmail(receiver, 'new-pm', [
|
||||||
|
{name: 'SENDER', content: getUserInfo(sender, ['name']).name},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiver.preferences.pushNotifications.newPM !== false) {
|
||||||
|
sendPushNotification(
|
||||||
|
receiver,
|
||||||
|
{
|
||||||
|
title: translate('newPM'),
|
||||||
|
message: translate('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
|
||||||
|
identifier: 'newPM',
|
||||||
|
category: 'newPM',
|
||||||
|
payload: {replyTo: sender._id},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageSent;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserInbox (user, options = {asArray: true, page: 0, conversation: null}) {
|
export async function getUserInbox (user, options = {asArray: true, page: 0, conversation: null}) {
|
||||||
if (typeof options.asArray === 'undefined') {
|
if (typeof options.asArray === 'undefined') {
|
||||||
options.asArray = true;
|
options.asArray = true;
|
||||||
@@ -40,34 +63,31 @@ export async function getUserInbox (user, options = {asArray: true, page: 0, con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listConversations (user) {
|
export async function listConversations (owner) {
|
||||||
let query = Inbox
|
let query = Inbox
|
||||||
.aggregate([
|
.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
ownerId: user._id,
|
ownerId: owner._id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: '$uuid',
|
_id: '$uuid',
|
||||||
|
user: {$first: '$user' },
|
||||||
|
username: {$first: '$username' },
|
||||||
timestamp: {$max: '$timestamp'}, // sort before group doesn't work - use the max value to sort it again after
|
timestamp: {$max: '$timestamp'}, // sort before group doesn't work - use the max value to sort it again after
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const conversationsList = orderBy(await query.exec(), ['timestamp'], ['desc']).map(c => c._id);
|
const conversationsList = orderBy(await query.exec(), ['timestamp'], ['desc']);
|
||||||
|
|
||||||
const users = await User.find({_id: {$in: conversationsList}})
|
const conversations = conversationsList.map(({_id, user, username, timestamp}) => ({
|
||||||
.select('_id profile.name auth.local.username')
|
uuid: _id,
|
||||||
.lean()
|
user,
|
||||||
.exec();
|
username,
|
||||||
|
timestamp,
|
||||||
const usersMap = keyBy(users, '_id');
|
|
||||||
const conversations = conversationsList.map(userId => ({
|
|
||||||
uuid: usersMap[userId]._id,
|
|
||||||
user: usersMap[userId].profile.name,
|
|
||||||
username: usersMap[userId].auth.local.username,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return conversations;
|
return conversations;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const v4RouterOverrides = [
|
|||||||
'DELETE-/user/messages/:id',
|
'DELETE-/user/messages/:id',
|
||||||
'DELETE-/user/messages',
|
'DELETE-/user/messages',
|
||||||
'POST-/coupons/enter/:code',
|
'POST-/coupons/enter/:code',
|
||||||
|
'GET-/inbox/messages',
|
||||||
];
|
];
|
||||||
|
|
||||||
const v4Router = express.Router(); // eslint-disable-line new-cap
|
const v4Router = express.Router(); // eslint-disable-line new-cap
|
||||||
|
|||||||
@@ -98,8 +98,14 @@ export function setUserStyles (newMessage, user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contributorCopy = user.contributor;
|
||||||
|
if (contributorCopy && contributorCopy.toObject) {
|
||||||
|
contributorCopy = contributorCopy.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
newMessage.contributor = contributorCopy;
|
||||||
newMessage.userStyles = userStyles;
|
newMessage.userStyles = userStyles;
|
||||||
newMessage.markModified('userStyles');
|
newMessage.markModified('userStyles contributor');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageDefaults (msg, user, client, info = {}) {
|
export function messageDefaults (msg, user, client, info = {}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user