699 lines
19 KiB
Vue
699 lines
19 KiB
Vue
<template>
|
|
<b-modal
|
|
id="inbox-modal"
|
|
title
|
|
:hide-footer="true"
|
|
size="lg"
|
|
@shown="onModalShown"
|
|
@hide="onModalHide"
|
|
>
|
|
<div
|
|
slot="modal-header"
|
|
class="header-wrap container align-items-center"
|
|
>
|
|
<div class="row align-items-center">
|
|
<div class="col-4">
|
|
<div class="row align-items-center">
|
|
<div class="col-2">
|
|
<div
|
|
class="svg-icon envelope"
|
|
v-html="icons.messageIcon"
|
|
></div>
|
|
</div>
|
|
<div class="col-6">
|
|
<h2
|
|
v-once
|
|
class="text-center"
|
|
>
|
|
{{ $t('messages') }}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-4 offset-3">
|
|
<toggle-switch
|
|
class="float-right"
|
|
:label="optTextSet.switchDescription"
|
|
:checked="!user.inbox.optOut"
|
|
:hover-text="optTextSet.popoverText"
|
|
@change="toggleOpt()"
|
|
/>
|
|
</div>
|
|
<div class="col-1">
|
|
<div class="close">
|
|
<span
|
|
class="svg-icon inline icon-10"
|
|
aria-hidden="true"
|
|
@click="close()"
|
|
v-html="icons.svgClose"
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-4 sidebar">
|
|
<div class="search-section">
|
|
<b-form-input
|
|
v-model="search"
|
|
:placeholder="$t('search')"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="filtersConversations.length === 0"
|
|
class="empty-messages text-center"
|
|
>
|
|
<div
|
|
class="svg-icon envelope"
|
|
v-html="icons.messageIcon"
|
|
></div>
|
|
<h4 v-once>
|
|
{{ $t('emptyMessagesLine1') }}
|
|
</h4>
|
|
<p v-if="!user.flags.chatRevoked">
|
|
{{ $t('emptyMessagesLine2') }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-if="filtersConversations.length > 0"
|
|
class="conversations"
|
|
>
|
|
<div
|
|
v-for="conversation in filtersConversations"
|
|
:key="conversation.key"
|
|
class="conversation"
|
|
:class="{active: selectedConversation.key === conversation.key}"
|
|
@click="selectConversation(conversation.key)"
|
|
>
|
|
<div>
|
|
<h3 :class="userLevelStyle(conversation)">
|
|
{{ conversation.name }}
|
|
<div
|
|
class="svg-icon"
|
|
v-html="tierIcon(conversation)"
|
|
></div>
|
|
</h3>
|
|
</div>
|
|
<div class="time">
|
|
<span
|
|
v-if="conversation.username"
|
|
class="mr-1"
|
|
>@{{ conversation.username }} •</span>
|
|
<span v-if="conversation.date">{{ conversation.date | timeAgo }}</span>
|
|
</div>
|
|
<div
|
|
class="messagePreview"
|
|
>
|
|
{{ conversation.lastMessageText
|
|
? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-8 messages d-flex flex-column justify-content-between">
|
|
<div
|
|
v-if="!selectedConversation.key"
|
|
class="empty-messages text-center"
|
|
>
|
|
<div
|
|
class="svg-icon envelope"
|
|
v-html="icons.messageIcon"
|
|
></div>
|
|
<h4>{{ placeholderTexts.title }}</h4>
|
|
<p v-html="placeholderTexts.description"></p>
|
|
</div>
|
|
<div
|
|
v-if="selectedConversation && selectedConversationMessages.length === 0"
|
|
class="empty-messages text-center"
|
|
>
|
|
<p>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</p>
|
|
</div>
|
|
<chat-messages
|
|
v-if="selectedConversation && selectedConversationMessages.length > 0"
|
|
ref="chatscroll"
|
|
class="message-scroll"
|
|
:chat="selectedConversationMessages"
|
|
:inbox="true"
|
|
:can-load-more="canLoadMore"
|
|
:is-loading="messagesLoading"
|
|
@message-removed="messageRemoved"
|
|
@triggerLoad="infiniteScrollTrigger"
|
|
/>
|
|
<div
|
|
v-if="user.inbox.optOut && selectedConversation.key"
|
|
class="pm-disabled-caption text-center"
|
|
>
|
|
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
|
|
<p>{{ $t('PMDisabledCaptionText') }}</p>
|
|
</div>
|
|
<div
|
|
v-if="selectedConversation.key && !user.flags.chatRevoked"
|
|
class="new-message-row"
|
|
>
|
|
<textarea
|
|
v-model="newMessage"
|
|
maxlength="3000"
|
|
@keyup.ctrl.enter="sendPrivateMessage()"
|
|
></textarea>
|
|
<button
|
|
class="btn btn-secondary"
|
|
@click="sendPrivateMessage()"
|
|
>
|
|
{{ $t('send') }}
|
|
</button>
|
|
<div class="row">
|
|
<span class="ml-3">{{ currentLength }} / 3000</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</b-modal>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
#inbox-modal .modal-body {
|
|
padding-top: 0px;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss" scoped>
|
|
@import '~@/assets/scss/colors.scss';
|
|
|
|
.header-wrap {
|
|
padding: 0.5em;
|
|
|
|
h2 {
|
|
margin: 0;
|
|
line-height: 1;
|
|
}
|
|
}
|
|
|
|
h3 {
|
|
margin: 0rem;
|
|
|
|
.svg-icon {
|
|
width: 10px;
|
|
display: inline-block;
|
|
margin-left: .5em;
|
|
}
|
|
}
|
|
|
|
.envelope {
|
|
color: $gray-400 !important;
|
|
margin: 0;
|
|
}
|
|
|
|
.sidebar {
|
|
background-color: $gray-700;
|
|
min-height: 540px;
|
|
padding: 0;
|
|
|
|
.search-section {
|
|
padding: 1em;
|
|
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
|
|
}
|
|
}
|
|
|
|
.messages {
|
|
position: relative;
|
|
padding-left: 0;
|
|
padding-bottom: 6em;
|
|
height: 540px;
|
|
}
|
|
|
|
.message-scroll {
|
|
max-height: 500px;
|
|
overflow-x: scroll;
|
|
|
|
@media (min-width: 992px) {
|
|
overflow-x: hidden;
|
|
overflow-y: scroll;
|
|
}
|
|
}
|
|
|
|
.to-form input {
|
|
width: 60%;
|
|
display: inline-block;
|
|
margin-left: 1em;
|
|
}
|
|
|
|
.empty-messages {
|
|
margin-top: 10em;
|
|
color: $gray-400;
|
|
padding: 1em;
|
|
|
|
h4 {
|
|
color: $gray-400;
|
|
margin-top: 1em;
|
|
}
|
|
|
|
.envelope {
|
|
width: 30px;
|
|
margin: 0 auto;
|
|
}
|
|
}
|
|
|
|
.pm-disabled-caption {
|
|
|
|
padding-top: 1em;
|
|
background-color: $gray-700;
|
|
z-index: 2;
|
|
|
|
h4, p {
|
|
color: $gray-300;
|
|
}
|
|
|
|
h4 {
|
|
margin-top: 0;
|
|
margin-bottom: 0.4em;
|
|
}
|
|
|
|
p {
|
|
font-size: 12px;
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
.new-message-row {
|
|
background-color: $gray-700;
|
|
position: absolute;
|
|
bottom: 0;
|
|
height: 88px;
|
|
width: 100%;
|
|
padding: 1em;
|
|
|
|
textarea {
|
|
height: 80%;
|
|
display: inline-block;
|
|
vertical-align: bottom;
|
|
width: 80%;
|
|
}
|
|
|
|
button {
|
|
vertical-align: bottom;
|
|
display: inline-block;
|
|
box-shadow: none;
|
|
margin-left: 1em;
|
|
}
|
|
}
|
|
|
|
.conversations {
|
|
max-height: 400px;
|
|
overflow-x: hidden;
|
|
overflow-y: scroll;
|
|
}
|
|
|
|
.conversation {
|
|
padding: 1.5em;
|
|
background: $white;
|
|
}
|
|
|
|
.conversation.active {
|
|
border: 1px solid $purple-400;
|
|
}
|
|
|
|
.conversation:hover {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.time {
|
|
font-size: 12px;
|
|
color: $gray-200;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.messagePreview {
|
|
display: block;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
word-break: break-word;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import Vue from 'vue';
|
|
import moment from 'moment';
|
|
import filter from 'lodash/filter';
|
|
import groupBy from 'lodash/groupBy';
|
|
import orderBy from 'lodash/orderBy';
|
|
import habiticaMarkdown from 'habitica-markdown';
|
|
import axios from 'axios';
|
|
import { mapState } from '@/libs/store';
|
|
import styleHelper from '@/mixins/styleHelper';
|
|
import toggleSwitch from '@/components/ui/toggleSwitch';
|
|
|
|
import chatMessages from '../chat/chatMessages';
|
|
import messageIcon from '@/assets/svg/message.svg';
|
|
import svgClose from '@/assets/svg/close.svg';
|
|
import tier1 from '@/assets/svg/tier-1.svg';
|
|
import tier2 from '@/assets/svg/tier-2.svg';
|
|
import tier3 from '@/assets/svg/tier-3.svg';
|
|
import tier4 from '@/assets/svg/tier-4.svg';
|
|
import tier5 from '@/assets/svg/tier-5.svg';
|
|
import tier6 from '@/assets/svg/tier-6.svg';
|
|
import tier7 from '@/assets/svg/tier-7.svg';
|
|
import tier8 from '@/assets/svg/tier-mod.svg';
|
|
import tier9 from '@/assets/svg/tier-staff.svg';
|
|
import tierNPC from '@/assets/svg/tier-npc.svg';
|
|
|
|
export default {
|
|
components: {
|
|
chatMessages,
|
|
toggleSwitch,
|
|
},
|
|
filters: {
|
|
timeAgo (value) {
|
|
return moment(new Date(value)).fromNow();
|
|
},
|
|
},
|
|
mixins: [styleHelper],
|
|
data () {
|
|
return {
|
|
icons: Object.freeze({
|
|
messageIcon,
|
|
svgClose,
|
|
tier1,
|
|
tier2,
|
|
tier3,
|
|
tier4,
|
|
tier5,
|
|
tier6,
|
|
tier7,
|
|
tier8,
|
|
tier9,
|
|
tierNPC,
|
|
}),
|
|
displayCreate: true,
|
|
selectedConversation: {},
|
|
search: '',
|
|
newMessage: '',
|
|
showPopover: false,
|
|
messages: [],
|
|
messagesByConversation: {}, // cache {uuid: []}
|
|
loadedConversations: [],
|
|
loaded: false,
|
|
messagesLoading: false,
|
|
initiatedConversation: null,
|
|
updateConversionsCounter: 0,
|
|
};
|
|
},
|
|
computed: {
|
|
...mapState({ user: 'user.data' }),
|
|
canLoadMore () {
|
|
return this.selectedConversation && this.selectedConversation.canLoadMore;
|
|
},
|
|
conversations () {
|
|
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
|
|
|
|
// Add placeholder for new conversations
|
|
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
|
inboxGroup[this.initiatedConversation.uuid] = [{
|
|
uuid: this.initiatedConversation.uuid,
|
|
user: this.initiatedConversation.user,
|
|
username: this.initiatedConversation.username,
|
|
contributor: this.initiatedConversation.contributor,
|
|
id: '',
|
|
text: '',
|
|
timestamp: new Date(),
|
|
}];
|
|
}
|
|
// Create conversation objects
|
|
const convos = [];
|
|
for (const key of Object.keys(inboxGroup)) {
|
|
const recentMessage = inboxGroup[key][0];
|
|
|
|
const convoModel = {
|
|
key: recentMessage.uuid,
|
|
// Handles case where from user sent
|
|
// the only message or the to user sent the only message
|
|
name: recentMessage.user,
|
|
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
|
|
date: recentMessage.timestamp,
|
|
lastMessageText: recentMessage.text,
|
|
canLoadMore: true,
|
|
page: 0,
|
|
};
|
|
|
|
convos.push(convoModel);
|
|
}
|
|
|
|
return convos;
|
|
},
|
|
// Separate from selectedConversation which
|
|
// is not computed so messages don't update automatically
|
|
selectedConversationMessages () {
|
|
// Vue-subscribe to changes
|
|
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
|
|
|
|
const selectedConversationKey = this.selectedConversation.key;
|
|
const selectedConversation = this.messagesByConversation[selectedConversationKey];
|
|
this.messages = selectedConversation || []; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
|
|
|
|
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
|
|
|
|
if (subScribeToUpdate) {
|
|
return ordered;
|
|
}
|
|
|
|
return [];
|
|
},
|
|
filtersConversations () {
|
|
// Vue-subscribe to changes
|
|
const subScribeToUpdate = this.updateConversionsCounter > -1;
|
|
|
|
const filtered = subScribeToUpdate && !this.search
|
|
? this.conversations
|
|
: filter(
|
|
this.conversations,
|
|
conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1,
|
|
);
|
|
|
|
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
|
|
|
|
return ordered;
|
|
},
|
|
currentLength () {
|
|
return this.newMessage.length;
|
|
},
|
|
placeholderTexts () {
|
|
if (this.user.flags.chatRevoked) {
|
|
return {
|
|
title: this.$t('PMPlaceholderTitleRevoked'),
|
|
description: this.$t('chatPrivilegesRevoked'),
|
|
};
|
|
}
|
|
return {
|
|
title: this.$t('PMPlaceholderTitle'),
|
|
description: this.$t('PMPlaceholderDescription'),
|
|
};
|
|
},
|
|
optTextSet () {
|
|
if (!this.user.inbox.optOut) {
|
|
return {
|
|
switchDescription: this.$t('PMReceive'),
|
|
popoverText: this.$t('PMEnabledOptPopoverText'),
|
|
};
|
|
}
|
|
return {
|
|
switchDescription: this.$t('PMReceive'),
|
|
popoverText: this.$t('PMDisabledOptPopoverText'),
|
|
};
|
|
},
|
|
},
|
|
mounted () {
|
|
this.$root.$on('habitica::new-inbox-message', data => {
|
|
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
|
|
|
// Wait for messages to be loaded
|
|
const unwatchLoaded = this.$watch('loaded', loaded => {
|
|
if (!loaded) return;
|
|
|
|
const conversation = this.conversations.find(convo => convo.key === data.userIdToMessage);
|
|
if (loaded) setImmediate(() => unwatchLoaded());
|
|
|
|
if (conversation) {
|
|
this.selectConversation(data.userIdToMessage);
|
|
return;
|
|
}
|
|
|
|
this.initiatedConversation = {
|
|
uuid: data.userIdToMessage,
|
|
user: data.displayName,
|
|
username: data.username,
|
|
backer: data.backer,
|
|
contributor: data.contributor,
|
|
};
|
|
|
|
this.selectConversation(data.userIdToMessage);
|
|
}, { immediate: true });
|
|
});
|
|
},
|
|
destroyed () {
|
|
this.$root.$off('habitica::new-inbox-message');
|
|
},
|
|
methods: {
|
|
async onModalShown () {
|
|
this.loaded = false;
|
|
|
|
const conversationRes = await axios.get('/api/v4/inbox/conversations');
|
|
this.loadedConversations = conversationRes.data.data;
|
|
|
|
this.loaded = true;
|
|
},
|
|
onModalHide () {
|
|
// reset everything
|
|
this.loadedConversations = [];
|
|
this.loaded = false;
|
|
this.initiatedConversation = null;
|
|
this.messagesByConversation = {};
|
|
this.selectedConversation = {};
|
|
},
|
|
messageRemoved (message) {
|
|
const messages = this.messagesByConversation[this.selectedConversation.key];
|
|
|
|
const messageIndex = messages.findIndex(msg => msg.id === message.id);
|
|
if (messageIndex !== -1) messages.splice(messageIndex, 1);
|
|
if (this.selectedConversationMessages.length === 0) {
|
|
this.initiatedConversation = {
|
|
uuid: this.selectedConversation.key,
|
|
user: this.selectedConversation.name,
|
|
username: this.selectedConversation.username,
|
|
backer: this.selectedConversation.backer,
|
|
contributor: this.selectedConversation.contributor,
|
|
};
|
|
}
|
|
},
|
|
toggleClick () {
|
|
this.displayCreate = !this.displayCreate;
|
|
},
|
|
toggleOpt () {
|
|
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
|
},
|
|
async selectConversation (key) {
|
|
const convoFound = this.conversations.find(conversation => conversation.key === key);
|
|
|
|
this.selectedConversation = convoFound || {};
|
|
|
|
if (!this.messagesByConversation[this.selectedConversation.key]) {
|
|
await this.loadMessages();
|
|
}
|
|
|
|
Vue.nextTick(() => {
|
|
if (!this.$refs.chatscroll) return;
|
|
const chatscroll = this.$refs.chatscroll.$el;
|
|
chatscroll.scrollTop = chatscroll.scrollHeight;
|
|
});
|
|
},
|
|
sendPrivateMessage () {
|
|
if (!this.newMessage) return;
|
|
|
|
const messages = this.messagesByConversation[this.selectedConversation.key];
|
|
|
|
messages.push({
|
|
sent: true,
|
|
text: this.newMessage,
|
|
timestamp: new Date(),
|
|
toUser: this.selectedConversation.name,
|
|
toUserName: this.selectedConversation.username,
|
|
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,
|
|
fromUUID: this.user._id,
|
|
user: this.user.profile.name,
|
|
username: this.user.auth.local.username,
|
|
contributor: this.user.contributor,
|
|
backer: this.user.backer,
|
|
});
|
|
|
|
// Remove the placeholder message
|
|
if (
|
|
this.initiatedConversation
|
|
&& this.initiatedConversation.uuid === this.selectedConversation.key
|
|
) {
|
|
this.loadedConversations.unshift(this.initiatedConversation);
|
|
this.initiatedConversation = null;
|
|
}
|
|
|
|
this.selectedConversation.lastMessageText = this.newMessage;
|
|
this.selectedConversation.date = new Date();
|
|
|
|
Vue.nextTick(() => {
|
|
if (!this.$refs.chatscroll) return;
|
|
const chatscroll = this.$refs.chatscroll.$el;
|
|
chatscroll.scrollTop = chatscroll.scrollHeight;
|
|
});
|
|
|
|
this.$store.dispatch('members:sendPrivateMessage', {
|
|
toUserId: this.selectedConversation.key,
|
|
message: this.newMessage,
|
|
}).then(response => {
|
|
const newMessage = response.data.data.message;
|
|
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 += 1;
|
|
});
|
|
|
|
this.newMessage = '';
|
|
},
|
|
close () {
|
|
this.$root.$emit('bv::hide::modal', 'inbox-modal');
|
|
},
|
|
tierIcon (message) {
|
|
const isNPC = Boolean(message.backer && message.backer.npc);
|
|
if (isNPC) {
|
|
return this.icons.tierNPC;
|
|
}
|
|
if (!message.contributor) return null;
|
|
return this.icons[`tier${message.contributor.level}`];
|
|
},
|
|
removeTags (html) {
|
|
const tmp = document.createElement('DIV');
|
|
tmp.innerHTML = html;
|
|
return tmp.textContent || tmp.innerText || '';
|
|
},
|
|
parseMarkdown (text) {
|
|
if (!text) return null;
|
|
return habiticaMarkdown.render(String(text));
|
|
},
|
|
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.selectedConversation.page += 1;
|
|
return this.loadMessages();
|
|
},
|
|
async loadMessages () {
|
|
this.messagesLoading = true;
|
|
|
|
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${this.selectedConversation.key}&page=${this.selectedConversation.page}`;
|
|
const res = await axios.get(requestUrl);
|
|
const loadedMessages = res.data.data;
|
|
|
|
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || []; // eslint-disable-line max-len
|
|
const loadedMessagesToAdd = loadedMessages
|
|
.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1); // eslint-disable-line max-len
|
|
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
|
|
|
|
// only show the load more Button if the max count was returned
|
|
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
|
|
this.messagesLoading = false;
|
|
},
|
|
},
|
|
};
|
|
</script>
|