PMs rebuild (#11360)

* feat(messages): big PMs refactor

* add private messages route

* move to page

* WIP - header + begin with the sidebar

* extract userLabel + style sidebar + extract converstation item

* correct conversation item style

* toggle switch style

* add contributor / backer to conversation user-label

* fix shadows

* fix the conversations list (ignoring own sent)

* selected conversation label

* faceAvatar component

* fix message / avatar height

* fix message list / empty messages height

* new message padding/styles/functionality - finished sidebar conversation styling -

* fix loading messages + perfect-scrollbar

* fix load more line

* fix loading label

* open new conversation from outside

* if the user doesn't have avatar-data inside the conversation and does not exist anymore, just load/set the user name

* search bar new icon / style

* block using from conversation context-menu

* fix lint

* fix merge / lint

* fix merge

* first separate page

* fix tooltips + full width private message + card max width + more responsive

* separate conversations methods, to prevent circular deps

* update eslint config

* fix open new private message

* remove unneeded close icon + fix toggle-switch layout

* same content height on empty conversations - remove border / box-shadow

* canLoadMore = false

* remove inbox conditions on chat components

* hide footer / fix empty sidebar

* floating shadow

* remove tooltip on selected conversation user + pm always full-size

* show avatar on empty conversation

* disable face-avatar

* fix faceAvatar + story

* fix loading conversation messages while switching the conversation

* refresh private-messages page when you are already on it

* add countbadge knob to change the example

* fix lint

* fix hide tooltip + align header correctly

* disable perfect scroll

* load messages on refresh event

* fix header label + conversation actions not breaking layout on hover

* add gifting banner to the max height calculation

* correct chunk name

Co-authored-by: negue <negue@users.noreply.github.com>
Co-authored-by: Matteo Pagliazzi <matteopagliazzi@gmail.com>
This commit is contained in:
Sabe Jones
2020-01-12 12:34:40 -06:00
committed by negue
parent da878dfa1a
commit 8b569e2136
38 changed files with 2496 additions and 877 deletions
@@ -5,7 +5,7 @@
class="mentioned-icon"
></div>
<div
v-if="!inbox && user.contributor.admin && msg.flagCount"
v-if="user.contributor.admin && msg.flagCount"
class="message-hidden"
>
{{ flagCountDescription }}
@@ -27,8 +27,7 @@
class="mr-1"
></span>
<span
v-b-tooltip
:title="msg.timestamp | date"
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p>
@@ -37,21 +36,12 @@
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
<div
v-if="isMessageReported && (inbox === true)"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span>
<br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
v-if="!inbox"
class="action d-flex align-items-center"
@click="copyAsTodo(msg)"
>
@@ -62,7 +52,7 @@
<div>{{ $t('copyAsTodo') }}</div>
</div>
<div
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system'))
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
&& (!isMessageReported || user.contributor.admin)"
class="action d-flex align-items-center"
@click="report(msg)"
@@ -77,7 +67,7 @@
</div>
</div>
<div
v-if="msg.uuid === user._id || inbox || user.contributor.admin"
v-if="msg.uuid === user._id || user.contributor.admin"
class="action d-flex align-items-center"
@click="remove()"
>
@@ -91,7 +81,6 @@
</div>
</div>
<div
v-if="!inbox"
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
class="ml-auto d-flex"
>
@@ -121,7 +110,7 @@
></div>
</div>
</div>
<span v-if="!msg.likes[user._id] && !inbox">{{ $t('like') }}</span>
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
</div>
</div>
</div>
@@ -205,15 +194,9 @@
color: $purple-400;
}
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
@@ -244,10 +227,6 @@ export default {
},
props: {
msg: {},
inbox: {
type: Boolean,
default: false,
},
groupId: {},
},
data () {
@@ -311,6 +290,10 @@ export default {
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)';
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
@@ -372,11 +355,6 @@ export default {
const message = this.msg;
this.$emit('message-removed', message);
if (this.inbox) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
return;
}
await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId,
chatId: message.id,
@@ -35,13 +35,11 @@
v-for="msg in messages"
v-if="chat && canViewFlag(msg)"
:key="msg.id"
:class="{row: inbox}"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div
v-if="user._id !== msg.uuid"
class="d-flex"
:class="{'flex-grow-1': inbox}"
>
<avatar
v-if="msg.userStyles
@@ -51,16 +49,13 @@
:avatar-only="true"
:override-top-padding="'14px'"
:hide-class-badge="true"
:class="{'inbox-avatar-left': inbox}"
@click.native="showMemberModal(msg.uuid)"
/>
<div
class="card"
:class="{'col-10': inbox}"
>
<chat-card
:msg="msg"
:inbox="inbox"
:group-id="groupId"
@message-liked="messageLiked"
@message-removed="messageRemoved"
@@ -72,15 +67,12 @@
<div
v-if="user._id === msg.uuid"
class="d-flex"
:class="{'flex-grow-1': inbox}"
>
<div
class="card"
:class="{'col-10': inbox}"
>
<chat-card
:msg="msg"
:inbox="inbox"
:group-id="groupId"
@message-liked="messageLiked"
@message-removed="messageRemoved"
@@ -95,7 +87,6 @@
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
:class="{'inbox-avatar-right': inbox}"
@click.native="showMemberModal(msg.uuid)"
/>
</div>
@@ -144,16 +135,6 @@
margin-right: 2rem;
}
.inbox-avatar-left {
margin-left: -1rem;
margin-right: 2.5rem;
min-width: 5rem;
}
.inbox-avatar-right {
margin-left: -3.5rem;
}
.hr {
width: 100%;
height: 20px;
@@ -209,10 +190,6 @@ export default {
},
props: {
chat: {},
inbox: {
type: Boolean,
default: false,
},
groupType: {},
groupId: {},
groupName: {},
@@ -260,12 +237,6 @@ export default {
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) {
if (message.uuid === this.user._id) return true;
@@ -380,11 +351,6 @@ export default {
this.chat.splice(chatIndex, 1, message);
},
messageRemoved (message) {
if (this.inbox) {
this.$emit('message-removed', message);
return;
}
const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id);
this.chat.splice(chatIndex, 1);
},
@@ -0,0 +1,47 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import FaceAvatar from './faceAvatar.vue';
import Avatar from './avatar.vue';
import { userStyles } from '../../config/storybook/mock.data';
import content from '../../../common/script/content/index';
import getters from '@/store/getters';
storiesOf('Face Avatar', module)
.add('simple', () => ({
components: { FaceAvatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
}))
.add('compare', () => ({
components: { FaceAvatar, Avatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
<avatar :member="user"></avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
state: {
content,
},
store: {
getters,
state: {
content,
},
},
}));
@@ -0,0 +1,154 @@
<template>
<div
class="face-avatar"
:style="{width, height}"
>
<div class="character-sprites">
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
<template v-for="(klass, item) in visualBuffs">
<span
v-if="member.stats.buffs[item] && showVisualBuffs"
:key="klass"
:class="klass"
></span>
</template>
<!-- Show flower ALL THE TIME!!!-->
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
<!-- Show avatar only if not currently affected by visual buff-->
<template v-if="showAvatar()">
<span :class="[skinClass]"></span><span :class="['head_0']"></span>
<template v-for="type in ['bangs', 'base', 'mustache', 'beard']">
<span
:key="type"
:class="[getHairClass(type)]"
></span>
</template>
<span :class="[getGearClass('body')]"></span>
<span :class="[getGearClass('eyewear')]"></span>
<span :class="[getGearClass('head')]"></span>
<span :class="[getGearClass('headAccessory')]"></span>
<span :class="['hair_flower_' + member.preferences.hair.flower]"></span>
</template>
<!-- Resting--><span
v-if="member.preferences.sleep"
class="zzz"
></span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.face-avatar {
width: 36px;
height: 36px;
border: solid 2px currentColor;
border-radius: 18px;
image-rendering: pixelated;
position: relative;
overflow: hidden;
}
.character-sprites {
width: 90px;
height: 90px;
margin: -25px -41px;
}
.character-sprites span {
position: absolute;
}
</style>
<script>
import { mapState } from '@/libs/store';
export default {
components: {
},
props: {
member: {
type: Object,
required: true,
},
avatarOnly: {
type: Boolean,
default: false,
},
hideClassBadge: {
type: Boolean,
default: false,
},
withBackground: {
type: Boolean,
},
overrideAvatarGear: {
type: Object,
},
width: {
type: Number,
default: 140,
},
height: {
type: Number,
default: 147,
},
showVisualBuffs: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
flatGear: 'content.gear.flat',
}),
hasClass () {
return this.$store.getters['members:hasClass'](this.member);
},
isBuffed () {
return this.$store.getters['members:isBuffed'](this.member);
},
visualBuffs () {
return {
snowball: 'snowman',
spookySparkles: 'ghost',
shinySeed: `avatar_floral_${this.member.stats.class}`,
seafoam: 'seafoam_star',
};
},
skinClass () {
const baseClass = `skin_${this.member.preferences.skin}`;
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
},
costumeClass () {
return this.member.preferences.costume ? 'costume' : 'equipped';
},
},
methods: {
getGearClass (gearType) {
let result = this.member.items.gear[this.costumeClass][gearType];
if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
result = this.overrideAvatarGear[gearType];
}
return result;
},
showAvatar () {
if (!this.showVisualBuffs) return true;
const { buffs } = this.member.stats;
return !buffs.snowball && !buffs.spookySparkles && !buffs.shinySeed && !buffs.seafoam;
},
getHairClass (type) {
const hairPref = this.member.preferences.hair;
return `hair_${type}_${hairPref[type]}_${hairPref.color}`;
},
},
};
</script>
@@ -536,13 +536,12 @@ export default {
},
methods: {
sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: member._id,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
this.$store.dispatch('user:newPrivateMessageTo', {
member,
});
this.$root.$emit('bv::hide::modal', 'members-modal');
this.$router.push('/private-messages');
},
async searchMembers (searchTerm = '') {
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({
@@ -8,7 +8,7 @@
<div
id="app-header"
class="row"
:class="{'hide-header': $route.name === 'groupPlan'}"
:class="{'hide-header': hideHeader}"
>
<members-modal :hide-badge="true" />
<member-details
@@ -171,6 +171,9 @@ export default {
sortedPartyMembers () {
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
},
hideHeader () {
return ['groupPlan', 'privateMessages'].includes(this.$route.name);
},
},
created () {
if (this.user.party && this.user.party._id) {
@@ -1,6 +1,5 @@
<template>
<div>
<inbox-modal />
<creator-intro />
<profileModal />
<report-flag-modal />
@@ -408,6 +407,7 @@
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/utils.scss';
@import '~@/assets/scss/variables.scss';
@media only screen and (max-width: 1200px) {
.chevron {
@@ -438,7 +438,7 @@
}
.topbar {
max-height: 56px;
max-height: $menuToolbarHeight;
.currency-tray {
margin-left: auto;
@@ -721,7 +721,6 @@ import chevronDownIcon from '@/assets/svg/chevron-down.svg';
import logo from '@/assets/svg/logo.svg';
import creatorIntro from '../creatorIntro';
import InboxModal from '../userMenu/inbox.vue';
import notificationMenu from './notificationsDropdown';
import profileModal from '../userMenu/profileModal';
import reportFlagModal from '../chat/reportFlagModal';
@@ -733,7 +732,6 @@ import userDropdown from './userDropdown';
export default {
components: {
creatorIntro,
InboxModal,
notificationMenu,
profileModal,
reportFlagModal,
@@ -25,7 +25,7 @@ export default {
props: ['notification', 'canRemove'],
methods: {
action () {
this.$root.$emit('bv::show::modal', 'inbox-modal');
this.$router.push('/private-messages');
},
},
};
@@ -139,7 +139,7 @@ import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
import CARD_RECEIVED from './notifications/cardReceived';
import NEW_INBOX_MESSAGE from './notifications/newInboxMessage';
import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage';
import NEW_CHAT_MESSAGE from './notifications/newChatMessage';
import WORLD_BOSS from './notifications/worldBoss';
import VERIFY_USERNAME from './notifications/verifyUsername';
@@ -33,7 +33,7 @@
<a
class="nav-link dropdown-item
dropdown-separated d-flex justify-content-between align-items-center"
@click.prevent="showInbox()"
@click.prevent="showPrivateMessages()"
>
<div>{{ $t('messages') }}</div>
<message-count
@@ -163,10 +163,15 @@ export default {
this.$store.state.avatarEditorOptions.subpage = subpage;
this.$root.$emit('bv::show::modal', 'avatar-modal');
},
showInbox () {
showPrivateMessages () {
markPMSRead(this.user);
axios.post('/api/v4/user/mark-pms-read');
this.$root.$emit('bv::show::modal', 'inbox-modal');
if (this.$router.history.current.name === 'privateMessages') {
this.$root.$emit('pm::refresh');
} else {
this.$router.push('/private-messages');
}
},
showProfile (startingPage) {
this.$router.push({ name: startingPage });
@@ -0,0 +1,235 @@
<template>
<div
class="conversation"
:class="{active: activeKey === uuid}"
@click="$emit('click', {})"
@mouseleave="hideDropDown()"
>
<div class="user">
<user-label
:backer="backer"
:contributor="contributor"
:name="displayName"
/><span
v-if="username"
class="username"
>@{{ username }}</span>
<div
v-if="lastMessageDate"
class="time"
>
{{ lastMessageDate | timeAgo }}
</div>
</div>
<div class="preview-row">
<div class="messagePreview">
{{ lastMessageText }}
</div>
<div
v-if="userLoggedIn.id !== uuid"
class="actions"
>
<b-dropdown
ref="dropdown"
class="action-dropdown"
right
toggle-class="btn-flat action-padding"
no-caret
variant="link"
size="lg"
>
<template slot="button-content">
<div
class="svg-icon inline dots"
v-html="icons.dots"
></div>
</template>
<b-dropdown-item @click="block()">
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.remove"
></div><span class="text">{{ $t('block') }}</span></span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</template>
<script>
import moment from 'moment';
import userLabel from '../userLabel';
import dots from '@/assets/svg/dots.svg';
import remove from '@/assets/svg/remove.svg';
import { mapState } from '@/libs/store';
export default {
components: {
userLabel,
},
props: [
'activeKey', 'uuid', 'backer', 'displayName',
'username', 'contributor', 'lastMessageText',
'lastMessageDate',
],
computed: {
...mapState({
userLoggedIn: 'user.data',
}),
},
data () {
return {
icons: Object.freeze({
dots,
remove,
}),
};
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
methods: {
hideDropDown () {
const { dropdown } = this.$refs;
if (dropdown) {
dropdown.hide();
}
},
block () {
this.$store.dispatch('user:block', {
uuid: this.uuid,
});
},
},
};
</script>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
.action-padding {
height: 24px !important;
width: 24px;
padding: 0 !important;
}
.action-dropdown {
.dropdown-item {
padding: 12px 16px;
.svg-icon {
width: 16px;
height: 16px;
}
}
.dots {
height: 16px;
width: 4px;
svg path {
fill: $purple-300
}
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.conversation {
padding: 1.5rem;
border-bottom: 1px solid $gray-500;
&:hover {
cursor: pointer;
background: #EEE;
.actions {
display: block;
}
}
&.active {
background-color: #f1edff;
}
.user {
display: flex;
flex-direction: row;
height: 20px;
.user-label {
flex: 1;
flex-grow: 0;
margin-right: 0.5rem;
white-space: nowrap;
}
.username {
flex: 1;
flex-grow: 0;
}
.time {
flex: 2;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 1rem;
}
}
.messagePreview {
//width: 100%;
height: 30px;
margin-right: 40px;
margin-top: 4px;
font-size: 12px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
color: $gray-100;
overflow: hidden;
// text-overflow: ellipsis;
}
}
.preview-row {
display: flex;
flex-direction: row;
position: relative;
.messagePreview {
flex: 1;
width: calc(100% - 16px);
}
}
.actions {
position: absolute;
right: 0;
display: none;
width: 16px;
margin-top: 4px;
.dots {
height: 16px;
width: 4px;
}
.action-icon {
margin-right: 1em;
}
}
</style>
@@ -0,0 +1,216 @@
<template>
<div class="card-body">
<user-link
:user-id="msg.uuid"
:name="msg.user"
:backer="msg.backer"
:contributor="msg.contributor"
/>
<p class="time">
<span
v-if="msg.username"
class="mr-1"
>@{{ msg.username }}</span><span
v-if="msg.username"
class="mr-1"
></span>
<span
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span>
</p>
<div
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
<div
v-if="isMessageReported"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span><br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
v-if="!isMessageReported"
class="action d-flex align-items-center"
@click="report(msg)"
>
<div
v-once
class="svg-icon"
v-html="icons.report"
></div>
<div v-once>
{{ $t('report') }}
</div>
</div>
<div
class="action d-flex align-items-center"
@click="remove()"
>
<div
v-once
class="svg-icon"
v-html="icons.delete"
></div>
<div v-once>
{{ $t('delete') }}
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem;
}
.at-text {
color: #6133b4;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.action {
display: inline-block;
color: $gray-200;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: $gray-300;
margin-right: .2em;
width: 16px;
}
}
.active {
color: $purple-300;
.svg-icon {
color: $purple-400;
}
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: $gray-50;
text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
}
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
import userLink from '../userLink';
import deleteIcon from '@/assets/svg/delete.svg';
import reportIcon from '@/assets/svg/report.svg';
import { highlightUsers } from '../../libs/highlightUsers';
export default {
components: {
userLink,
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
props: {
msg: {},
},
data () {
return {
icons: Object.freeze({
delete: deleteIcon,
report: reportIcon,
}),
reported: false,
};
},
computed: {
...mapState({ user: 'user.data' }),
isMessageReported () {
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
this.$emit('message-card-mounted');
},
methods: {
report () {
this.$root.$on('habitica:report-result', data => {
if (data.ok) {
this.reported = true;
}
this.$root.$off('habitica:report-result');
});
this.$root.$emit('habitica::report-chat', {
message: this.msg,
groupId: 'privateMessage',
});
},
async remove () {
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return;
const message = this.msg;
this.$emit('message-removed', message);
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
},
atHighlight (text) {
return highlightUsers(text, this.user.auth.local.username, this.user.profile.name);
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
},
};
</script>
@@ -0,0 +1,400 @@
<template>
<perfect-scrollbar
ref="container"
class="container-fluid"
:class="{'disable-perfect-scroll': disablePerfectScroll}"
:options="psOptions"
>
<div class="row loadmore">
<div v-if="canLoadMore && !isLoading">
<div class="loadmore-divider"></div>
<button
class="btn btn-secondary"
@click="triggerLoad()"
>
{{ $t('loadEarlierMessages') }}
</button>
<div class="loadmore-divider"></div>
</div>
<h2
v-show="isLoading"
class="col-12 loading"
>
{{ $t('loading') }}
</h2>
</div>
<div
v-for="(msg) in messages"
:key="msg.id"
class="row message-row"
:class="{ 'margin-right': user._id !== msg.uuid}"
>
<div
v-if="user._id !== msg.uuid"
class="d-flex flex-grow-1"
>
<avatar
v-if="msg.userStyles || (cachedProfileData[msg.uuid]
&& !cachedProfileData[msg.uuid].rejected)"
class="avatar-left"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:override-top-padding="'14px'"
:hide-class-badge="true"
@click.native="showMemberModal(msg.uuid)"
/>
<div class="card card-right">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
</div>
<div
v-if="user._id === msg.uuid"
class="d-flex flex-grow-1"
>
<div class="card card-left">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
<avatar
v-if="msg.userStyles
|| (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)"
class="avatar-right"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
@click.native="showMemberModal(msg.uuid)"
/>
</div>
</div>
</perfect-scrollbar>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css';
.disable-perfect-scroll {
overflow-y: inherit !important;
}
.avatar {
width: 15%;
min-width: 8rem;
height: 120px;
padding-top: 0 !important;
}
.avatar-left {
margin-left: -1rem;
}
.avatar-right {
margin-left: -1rem;
}
.card {
border: 0px;
margin-bottom: 1rem;
padding: 0rem;
width: 684px;
}
.message-row {
margin-left: 12px;
margin-right: 12px;
&:not(.margin-right) {
.d-flex {
justify-content: flex-end;
}
}
}
@media only screen and (max-width: 1200px) {
.card {
width: 100%;
}
}
@media only screen and (min-width: 1400px) {
.message-row {
margin-left: -15px;
margin-right: -30px;
}
}
.card-left {
border: 1px solid $purple-500;
}
.card-right {
border: 1px solid $gray-500;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.hr-middle {
font-size: 16px;
font-weight: bold;
font-family: 'Roboto Condensed';
line-height: 1.5;
text-align: center;
color: $gray-200;
background-color: $gray-700;
padding: .2em;
margin-top: .2em;
display: inline-block;
width: 100px;
}
.loadmore {
justify-content: center;
margin-right: 12px;
> 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;
}
}
.loading {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
</style>
<script>
import moment from 'moment';
import axios from 'axios';
import debounce from 'lodash/debounce';
import { PerfectScrollbar } from 'vue2-perfect-scrollbar';
import { mapState } from '@/libs/store';
import Avatar from '../avatar';
import messageCard from './messageCard';
export default {
components: {
Avatar,
messageCard,
PerfectScrollbar,
},
props: {
chat: {},
isLoading: Boolean,
canLoadMore: Boolean,
},
data () {
return {
currentDayDividerDisplay: moment().day(),
cachedProfileData: {},
currentProfileLoadedCount: 0,
currentProfileLoadedEnd: 10,
loading: false,
handleScrollBack: false,
lastOffset: -1,
disablePerfectScroll: false,
};
},
mounted () {
this.loadProfileCache();
this.$el.addEventListener('selectstart', () => this.handleSelectStart());
this.$el.addEventListener('mouseup', () => this.handleSelectChange());
},
created () {
window.addEventListener('scroll', this.handleScroll);
},
beforeDestroy () {
this.$el.removeEventListener('selectstart', () => this.handleSelectStart());
this.$el.removeEventListener('mouseup', () => this.handleSelectChange());
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll);
},
computed: {
...mapState({ user: 'user.data' }),
// @TODO: We need a different lazy load mechnism.
// But honestly, adding a paging route to chat would solve this
messages () {
this.loadProfileCache();
return this.chat;
},
psOptions () {
return {
suppressScrollX: true,
};
},
},
methods: {
handleScroll () {
this.loadProfileCache(window.scrollY / 1000);
},
async triggerLoad () {
const container = this.$refs.container.$el;
// get current offset
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll
// container.style.overflowY = 'hidden';
const canLoadMore = !this.isLoading && this.canLoadMore;
if (canLoadMore) {
const triggerLoadResult = this.$emit('triggerLoad');
await triggerLoadResult;
this.handleScrollBack = true;
}
},
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
this._loadProfileCache(screenPosition);
}, 1000),
async _loadProfileCache (screenPosition) {
if (this.loading) return;
this.loading = true;
const promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination
// @TODO: Remove this after enough messages are cached
if (!noProfilesLoaded && screenPosition
&& Math.floor(screenPosition) + 1 > this.currentProfileLoadedEnd / 10) {
this.currentProfileLoadedEnd = 10 * (Math.floor(screenPosition) + 1);
} else if (!noProfilesLoaded && screenPosition) {
return;
}
const aboutToCache = {};
this.messages.forEach(message => {
const { uuid } = message;
if (message.userStyles) {
this.$set(this.cachedProfileData, uuid, message.userStyles);
}
if (Boolean(uuid) && !this.cachedProfileData[uuid] && !aboutToCache[uuid]) {
if (uuid === 'system' || this.currentProfileLoadedCount === this.currentProfileLoadedEnd) return;
aboutToCache[uuid] = {};
promises.push(axios.get(`/api/v4/members/${uuid}`));
this.currentProfileLoadedCount += 1;
}
});
const results = await Promise.all(promises);
results.forEach(result => {
// We could not load the user. Maybe they were deleted.
// So, let's cache empty so we don't try again
if (!result || !result.data || result.status >= 400) {
return;
}
const userData = result.data.data;
this.$set(this.cachedProfileData, userData._id, userData);
});
// Merge in any attempts that were rejected so we don't attempt again
for (const uuid in aboutToCache) {
if (!this.cachedProfileData[uuid]) {
this.$set(this.cachedProfileData, uuid, { rejected: true });
}
}
this.loading = false;
},
displayDivider (message) {
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
this.currentDayDividerDisplay = moment(message.timestamp).day();
return true;
}
return false;
},
async showMemberModal (memberId) {
let profile = this.cachedProfileData[memberId];
if (!profile._id) {
const result = await this.$store.dispatch('members:fetchMember', { memberId });
if (result.response && result.response.status === 404) {
return this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('messageDeletedUser'),
type: 'error',
timeout: false,
});
}
this.cachedProfileData[memberId] = result.data.data;
profile = result.data.data;
}
// Open the modal only if the data is available
if (profile && !profile.rejected) {
this.$router.push({ name: 'userProfile', params: { userId: profile._id } });
}
return null;
},
itemWasMounted: debounce(function itemWasMounted () {
if (this.handleScrollBack) {
this.handleScrollBack = false;
const container = this.$refs.container.$el;
const offset = container.scrollHeight - container.clientHeight;
const newOffset = offset + this.lastOffset;
container.scrollTo(0, newOffset);
// enable scroll again
// container.style.overflowY = 'scroll';
}
}, 50),
messageRemoved (message) {
this.$emit('message-removed', message);
},
handleSelectStart () {
this.disablePerfectScroll = true;
},
handleSelectChange () {
this.disablePerfectScroll = false;
},
},
};
</script>
@@ -1,9 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import { withKnobs, number } from '@storybook/addon-knobs';
import CountBadge from './countBadge.vue';
storiesOf('Count Badge', module)
const stories = storiesOf('Count Badge', module);
stories.addDecorator(withKnobs);
stories
.add('simple', () => ({
components: { CountBadge },
template: `
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
<count-badge :count="count" :show="true"></count-badge>
</div>
`,
data () {
return {
count: 3,
};
props: {
count: {
default: number('Count', 3),
},
},
}));
@@ -2,14 +2,14 @@
<div class="popover-box">
<div
:id="containerId"
class="clearfix"
class="clearfix toggle-switch-outer"
>
<div
v-if="label"
class="float-left toggle-switch-description"
:class="hoverText ? 'hasPopOver' : ''"
>
{{ label }}
<span>{{ label }}</span>
</div>
<div class="toggle-switch float-left">
<input
@@ -53,9 +53,7 @@
}
.toggle-switch-description {
height: 20px;
&.hasPopOver {
&.hasPopOver span {
border-bottom: 1px dashed $gray-200;
}
}
+115
View File
@@ -0,0 +1,115 @@
<template>
<div
v-if="displayName"
v-b-tooltip.hover.top="tierTitle"
class="user-label"
:class="levelStyle()"
>
{{ displayName }}
<div
class="svg-icon"
v-html="tierIcon()"
></div>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
.user-label.no-tier {
color: $gray-50;
}
.user-label {
font-weight: bold;
margin-bottom: 0;
display: inline-block;
font-size: 16px;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
</style>
<script>
import achievementsLib from '@/../../common/script/libs/achievements';
import styleHelper from '@/mixins/styleHelper';
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 {
mixins: [styleHelper],
props: ['user', 'name', 'backer', 'contributor', 'hideTooltip'],
data () {
return {
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
};
},
computed: {
displayName () {
if (this.name) {
return this.name;
}
if (this.user && this.user.profile) {
return this.user.profile.name;
}
return null;
},
level () {
if (this.contributor) {
return this.contributor.level;
} if (this.user && this.user.contributor) {
return this.user.contributor.level;
}
return 0;
},
isNPC () {
if (this.backer) {
return this.backer.level;
} if (this.user && this.user.backer) {
return this.user.backer.level;
}
return false;
},
},
methods: {
tierIcon () {
if (this.isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${this.level}`];
},
tierTitle () {
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
},
levelStyle () {
return this.userLevelStyleFromLevel(this.level, this.isNPC);
},
},
};
</script>
@@ -1,698 +0,0 @@
<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>
@@ -859,13 +859,12 @@ export default {
window.history.replaceState(null, null, '');
},
sendMessage () {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: this.user._id,
displayName: this.user.profile.name,
username: this.user.auth.local.username,
backer: this.user.backer,
contributor: this.user.contributor,
this.$store.dispatch('user:newPrivateMessageTo', {
member: this.user,
});
this.$router.push('/private-messages');
this.$root.$emit('bv::hide::modal', 'profile');
},
getProgressDisplay () {
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];