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

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ yarn.lock
.elasticbeanstalk/* .elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml !.elasticbeanstalk/*.global.yml
# webstorm fake webpack for path intellisense
webpack.webstorm.config

View File

@@ -1,6 +1,37 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { configure } from '@storybook/vue'; import { configure } from '@storybook/vue';
import '../../src/assets/scss/index.scss'; import '../../src/assets/scss/index.scss';
import '../../src/assets/css/sprites.css';
import '../../src/assets/css/sprites/spritesmith-main-0.css';
import '../../src/assets/css/sprites/spritesmith-main-1.css';
import '../../src/assets/css/sprites/spritesmith-main-2.css';
import '../../src/assets/css/sprites/spritesmith-main-3.css';
import '../../src/assets/css/sprites/spritesmith-main-4.css';
import '../../src/assets/css/sprites/spritesmith-main-5.css';
import '../../src/assets/css/sprites/spritesmith-main-6.css';
import '../../src/assets/css/sprites/spritesmith-main-7.css';
import '../../src/assets/css/sprites/spritesmith-main-8.css';
import '../../src/assets/css/sprites/spritesmith-main-9.css';
import '../../src/assets/css/sprites/spritesmith-main-10.css';
import '../../src/assets/css/sprites/spritesmith-main-11.css';
import '../../src/assets/css/sprites/spritesmith-main-12.css';
import '../../src/assets/css/sprites/spritesmith-main-13.css';
import '../../src/assets/css/sprites/spritesmith-main-14.css';
import '../../src/assets/css/sprites/spritesmith-main-15.css';
import '../../src/assets/css/sprites/spritesmith-main-16.css';
import '../../src/assets/css/sprites/spritesmith-main-17.css';
import '../../src/assets/css/sprites/spritesmith-main-18.css';
import '../../src/assets/css/sprites/spritesmith-main-19.css';
import '../../src/assets/css/sprites/spritesmith-main-20.css';
import '../../src/assets/css/sprites/spritesmith-main-21.css';
import '../../src/assets/css/sprites/spritesmith-main-22.css';
import '../../src/assets/css/sprites/spritesmith-main-23.css';
import '../../src/assets/css/sprites/spritesmith-main-24.css';
import '../../src/assets/css/sprites/spritesmith-main-25.css';
import '../../src/assets/css/sprites/spritesmith-main-26.css';
import Vue from 'vue';
import StoreModule from '@/libs/store';
const req = require.context('../../src', true, /.stories.js$/); const req = require.context('../../src', true, /.stories.js$/);
@@ -8,4 +39,6 @@ function loadStories () {
req.keys().forEach(filename => req(filename)); req.keys().forEach(filename => req(filename));
} }
Vue.use(StoreModule);
configure(loadStories, module); configure(loadStories, module);

View File

@@ -0,0 +1,75 @@
export const userStyles = {
contributor: {
admin: true,
level: 9,
text: '',
},
items: {
gear: {
equipped: {
armor: 'armor_special_2',
head: 'head_special_2',
shield: 'shield_special_goldenknight',
headAccessory: 'headAccessory_base_0',
eyewear: 'eyewear_base_0',
weapon: 'weapon_special_1',
back: 'back_base_0',
},
costume: {
armor: 'armor_special_fallRogue',
head: 'head_special_fallRogue',
shield: 'shield_armoire_shieldOfDiamonds',
body: 'body_mystery_201706',
eyewear: 'eyewear_special_blackHalfMoon',
back: 'back_base_0',
headAccessory: 'headAccessory_special_wolfEars',
weapon: 'weapon_armoire_lamplighter',
},
},
},
preferences: {
hair: {
color: 'black', base: 0, bangs: 3, beard: 0, mustache: 0, flower: 0,
},
tasks: { groupByChallenge: false, confirmScoreNotes: false },
size: 'broad',
skin: 'wolf',
shirt: 'zombie',
chair: 'none',
sleep: true,
disableClasses: false,
background: 'midnight_castle',
costume: true,
},
stats: {
buffs: {
str: 0,
int: 0,
per: 0,
con: 0,
stealth: 0,
streaks: false,
snowball: false,
spookySparkles: false,
shinySeed: false,
seafoam: false,
},
training: {
int: 0, per: 0, str: 0, con: 0,
},
hp: 50,
mp: 158,
exp: 227,
gp: 464.31937261345155,
lvl: 17,
class: 'rogue',
points: 17,
str: 0,
con: 0,
int: 0,
per: 0,
toNextLevel: 380,
maxHealth: 50,
maxMP: 158,
},
};

View File

@@ -11845,6 +11845,11 @@
"sha.js": "^2.4.8" "sha.js": "^2.4.8"
} }
}, },
"perfect-scrollbar": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.4.0.tgz",
"integrity": "sha512-/2Sk/khljhdrsamjJYS5NjrH+GKEHEwh7zFSiYyxROyYKagkE4kSn2zDQDRTOMo8mpT2jikxx6yI1dG7lNP/hw=="
},
"performance-now": { "performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -12090,6 +12095,17 @@
"postcss": "^7.0.0" "postcss": "^7.0.0"
} }
}, },
"postcss-import": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-12.0.1.tgz",
"integrity": "sha512-3Gti33dmCjyKBgimqGxL3vcV8w9+bsHwO5UrBawp796+jdardbcFl4RP5w/76BwNL7aGzpKstIfF9I+kdE8pTw==",
"requires": {
"postcss": "^7.0.1",
"postcss-value-parser": "^3.2.3",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
}
},
"postcss-load-config": { "postcss-load-config": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.0.tgz",
@@ -13101,6 +13117,21 @@
"lodash": "^4.0.1" "lodash": "^4.0.1"
} }
}, },
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=",
"requires": {
"pify": "^2.3.0"
},
"dependencies": {
"pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
}
}
},
"read-pkg": { "read-pkg": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -15461,6 +15492,16 @@
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==" "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
}, },
"vue2-perfect-scrollbar": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/vue2-perfect-scrollbar/-/vue2-perfect-scrollbar-1.2.4.tgz",
"integrity": "sha512-RI4+OEKHQm5H+cWq0kaK8OxUB11NdSUQZ4yagD00eDRf9pd5SWo9B9J9K2z82nORM2IMw0RssxBXXUf09Ck20g==",
"requires": {
"cssnano": "^4.1.3",
"perfect-scrollbar": "^1.4.0",
"postcss-import": "^12.0.0"
}
},
"vuedraggable": { "vuedraggable": {
"version": "2.23.2", "version": "2.23.2",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",

View File

@@ -58,6 +58,7 @@
"vue-mugen-scroll": "^0.2.6", "vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.0.6", "vue-router": "^3.0.6",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"vue2-perfect-scrollbar": "^1.2.1",
"vuedraggable": "^2.23.1", "vuedraggable": "^2.23.1",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec", "vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
"webpack": "^4.41.5" "webpack": "^4.41.5"

View File

@@ -28,7 +28,10 @@
</div> </div>
<div <div
id="app" id="app"
:class="{'casting-spell': castingSpell}" :class="{
'casting-spell': castingSpell,
'resting': showRestingBanner
}"
> >
<banned-account-modal /> <banned-account-modal />
<amazon-payments-modal v-if="!isStaticPage" /> <amazon-payments-modal v-if="!isStaticPage" />
@@ -66,7 +69,10 @@
</div> </div>
<notifications-display /> <notifications-display />
<app-menu /> <app-menu />
<div class="container-fluid"> <div
class="container-fluid"
:class="{'no-margin': noMargin}"
>
<app-header /> <app-header />
<buyModal <buyModal
:item="selectedItemToBuy || {}" :item="selectedItemToBuy || {}"
@@ -83,7 +89,7 @@
<router-view /> <router-view />
</div> </div>
</div> </div>
<app-footer /> <app-footer v-if="!hideFooter" />
<audio <audio
id="sound" id="sound"
ref="sound" ref="sound"
@@ -97,13 +103,20 @@
<style lang='scss' scoped> <style lang='scss' scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/variables.scss';
#app { #app {
height: calc(100% - 56px); /* 56px is the menu */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
&.resting {
--banner-resting-height: #{$restingToolbarHeight};
}
&.giftingBanner {
--banner-gifting-height: 2.5rem;
}
} }
#loading-screen-inapp { #loading-screen-inapp {
@@ -148,6 +161,13 @@
flex: 1 0 auto; flex: 1 0 auto;
} }
.no-margin {
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
.notification { .notification {
border-radius: 1000px; border-radius: 1000px;
background-color: $green-10; background-color: $green-10;
@@ -160,7 +180,7 @@
.resting-banner { .resting-banner {
width: 100%; width: 100%;
min-height: 40px; height: $restingToolbarHeight;
background-color: $blue-10; background-color: $blue-10;
top: 0; top: 0;
z-index: 1300; z-index: 1300;
@@ -302,7 +322,13 @@ export default {
return this.$t(`tip${tipNumber}`); return this.$t(`tip${tipNumber}`);
}, },
showRestingBanner () { showRestingBanner () {
return !this.bannerHidden && this.user.preferences.sleep; return !this.bannerHidden && this.user && this.user.preferences.sleep;
},
noMargin () {
return ['privateMessages'].includes(this.$route.name);
},
hideFooter () {
return ['privateMessages'].includes(this.$route.name);
}, },
}, },
created () { created () {

View File

@@ -7,3 +7,6 @@ $npc_quests_flavor: 'nye';
$npc_seasonal_flavor: 'nye'; $npc_seasonal_flavor: 'nye';
$npc_timetravelers_flavor: 'winter'; $npc_timetravelers_flavor: 'winter';
$npc_tavern_flavor: 'nye'; $npc_tavern_flavor: 'nye';
$restingToolbarHeight: 40px;
$menuToolbarHeight: 56px;

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#A5A1AC" fill-rule="evenodd" d="M15.7 14.3l-4.8-4.8c.7-1 1.1-2.2 1.1-3.5 0-3.3-2.7-6-6-6S0 2.7 0 6s2.7 6 6 6c1.3 0 2.5-.4 3.5-1.1l4.8 4.8c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4zM6 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="24" viewBox="0 0 32 24">
<g fill="none" fill-rule="evenodd">
<path fill="#9A62FF" d="M28 0H4C1.79 0 0 1.79 0 4v16c0 2.21 1.79 4 4 4h24c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z"/>
<path fill="#BDA8FF" d="M28 20H4V4l12 10L28 4z"/>
<path fill="#D5C8FF" d="M28 20H4V7l12 10L28 7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -5,7 +5,7 @@
class="mentioned-icon" class="mentioned-icon"
></div> ></div>
<div <div
v-if="!inbox && user.contributor.admin && msg.flagCount" v-if="user.contributor.admin && msg.flagCount"
class="message-hidden" class="message-hidden"
> >
{{ flagCountDescription }} {{ flagCountDescription }}
@@ -27,8 +27,7 @@
class="mr-1" class="mr-1"
></span> ></span>
<span <span
v-b-tooltip v-b-tooltip.hover="messageDate"
:title="msg.timestamp | date"
>{{ msg.timestamp | timeAgo }}&nbsp;</span> >{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span> <span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p> </p>
@@ -37,21 +36,12 @@
class="text" class="text"
v-html="atHighlight(parseMarkdown(msg.text))" v-html="atHighlight(parseMarkdown(msg.text))"
></div> ></div>
<div
v-if="isMessageReported && (inbox === true)"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span>
<br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr> <hr>
<div <div
v-if="msg.id" v-if="msg.id"
class="d-flex" class="d-flex"
> >
<div <div
v-if="!inbox"
class="action d-flex align-items-center" class="action d-flex align-items-center"
@click="copyAsTodo(msg)" @click="copyAsTodo(msg)"
> >
@@ -62,7 +52,7 @@
<div>{{ $t('copyAsTodo') }}</div> <div>{{ $t('copyAsTodo') }}</div>
</div> </div>
<div <div
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')) v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
&& (!isMessageReported || user.contributor.admin)" && (!isMessageReported || user.contributor.admin)"
class="action d-flex align-items-center" class="action d-flex align-items-center"
@click="report(msg)" @click="report(msg)"
@@ -77,7 +67,7 @@
</div> </div>
</div> </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" class="action d-flex align-items-center"
@click="remove()" @click="remove()"
> >
@@ -91,7 +81,6 @@
</div> </div>
</div> </div>
<div <div
v-if="!inbox"
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}" v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
class="ml-auto d-flex" class="ml-auto d-flex"
> >
@@ -121,7 +110,7 @@
></div> ></div>
</div> </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> </div>
</div> </div>
@@ -205,15 +194,9 @@
color: $purple-400; color: $purple-400;
} }
} }
.reported {
margin-top: 18px;
color: $red-50;
}
</style> </style>
<script> <script>
import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp'; import escapeRegExp from 'lodash/escapeRegExp';
@@ -244,10 +227,6 @@ export default {
}, },
props: { props: {
msg: {}, msg: {},
inbox: {
type: Boolean,
default: false,
},
groupId: {}, groupId: {},
}, },
data () { data () {
@@ -311,6 +290,10 @@ export default {
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden'; if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)'; return 'Message hidden (shadow-muted)';
}, },
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
}, },
mounted () { mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a'); const links = this.$refs.markdownContainer.getElementsByTagName('a');
@@ -372,11 +355,6 @@ export default {
const message = this.msg; const message = this.msg;
this.$emit('message-removed', message); this.$emit('message-removed', message);
if (this.inbox) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
return;
}
await this.$store.dispatch('chat:deleteChat', { await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId, groupId: this.groupId,
chatId: message.id, chatId: message.id,

View File

@@ -35,13 +35,11 @@
v-for="msg in messages" v-for="msg in messages"
v-if="chat && canViewFlag(msg)" v-if="chat && canViewFlag(msg)"
:key="msg.id" :key="msg.id"
:class="{row: inbox}"
> >
<!-- eslint-enable vue/no-use-v-if-with-v-for --> <!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div <div
v-if="user._id !== msg.uuid" v-if="user._id !== msg.uuid"
class="d-flex" class="d-flex"
:class="{'flex-grow-1': inbox}"
> >
<avatar <avatar
v-if="msg.userStyles v-if="msg.userStyles
@@ -51,16 +49,13 @@
:avatar-only="true" :avatar-only="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
:hide-class-badge="true" :hide-class-badge="true"
:class="{'inbox-avatar-left': inbox}"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
<div <div
class="card" class="card"
:class="{'col-10': inbox}"
> >
<chat-card <chat-card
:msg="msg" :msg="msg"
:inbox="inbox"
:group-id="groupId" :group-id="groupId"
@message-liked="messageLiked" @message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@@ -72,15 +67,12 @@
<div <div
v-if="user._id === msg.uuid" v-if="user._id === msg.uuid"
class="d-flex" class="d-flex"
:class="{'flex-grow-1': inbox}"
> >
<div <div
class="card" class="card"
:class="{'col-10': inbox}"
> >
<chat-card <chat-card
:msg="msg" :msg="msg"
:inbox="inbox"
:group-id="groupId" :group-id="groupId"
@message-liked="messageLiked" @message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@@ -95,7 +87,6 @@
:avatar-only="true" :avatar-only="true"
:hide-class-badge="true" :hide-class-badge="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
:class="{'inbox-avatar-right': inbox}"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
</div> </div>
@@ -144,16 +135,6 @@
margin-right: 2rem; margin-right: 2rem;
} }
.inbox-avatar-left {
margin-left: -1rem;
margin-right: 2.5rem;
min-width: 5rem;
}
.inbox-avatar-right {
margin-left: -3.5rem;
}
.hr { .hr {
width: 100%; width: 100%;
height: 20px; height: 20px;
@@ -209,10 +190,6 @@ export default {
}, },
props: { props: {
chat: {}, chat: {},
inbox: {
type: Boolean,
default: false,
},
groupType: {}, groupType: {},
groupId: {}, groupId: {},
groupName: {}, groupName: {},
@@ -260,12 +237,6 @@ export default {
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight); this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll // disable scroll
container.style.overflowY = 'hidden'; 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;
@@ -380,11 +351,6 @@ export default {
this.chat.splice(chatIndex, 1, message); this.chat.splice(chatIndex, 1, message);
}, },
messageRemoved (message) { messageRemoved (message) {
if (this.inbox) {
this.$emit('message-removed', message);
return;
}
const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id); const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id);
this.chat.splice(chatIndex, 1); this.chat.splice(chatIndex, 1);
}, },

View File

@@ -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,
},
},
}));

View File

@@ -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>

View File

@@ -536,13 +536,12 @@ export default {
}, },
methods: { methods: {
sendMessage (member) { sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', { this.$store.dispatch('user:newPrivateMessageTo', {
userIdToMessage: member._id, member,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
}); });
this.$root.$emit('bv::hide::modal', 'members-modal');
this.$router.push('/private-messages');
}, },
async searchMembers (searchTerm = '') { async searchMembers (searchTerm = '') {
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({ this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({

View File

@@ -8,7 +8,7 @@
<div <div
id="app-header" id="app-header"
class="row" class="row"
:class="{'hide-header': $route.name === 'groupPlan'}" :class="{'hide-header': hideHeader}"
> >
<members-modal :hide-badge="true" /> <members-modal :hide-badge="true" />
<member-details <member-details
@@ -171,6 +171,9 @@ export default {
sortedPartyMembers () { sortedPartyMembers () {
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]); return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
}, },
hideHeader () {
return ['groupPlan', 'privateMessages'].includes(this.$route.name);
},
}, },
created () { created () {
if (this.user.party && this.user.party._id) { if (this.user.party && this.user.party._id) {

View File

@@ -1,6 +1,5 @@
<template> <template>
<div> <div>
<inbox-modal />
<creator-intro /> <creator-intro />
<profileModal /> <profileModal />
<report-flag-modal /> <report-flag-modal />
@@ -408,6 +407,7 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/utils.scss'; @import '~@/assets/scss/utils.scss';
@import '~@/assets/scss/variables.scss';
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
.chevron { .chevron {
@@ -438,7 +438,7 @@
} }
.topbar { .topbar {
max-height: 56px; max-height: $menuToolbarHeight;
.currency-tray { .currency-tray {
margin-left: auto; margin-left: auto;
@@ -721,7 +721,6 @@ import chevronDownIcon from '@/assets/svg/chevron-down.svg';
import logo from '@/assets/svg/logo.svg'; import logo from '@/assets/svg/logo.svg';
import creatorIntro from '../creatorIntro'; import creatorIntro from '../creatorIntro';
import InboxModal from '../userMenu/inbox.vue';
import notificationMenu from './notificationsDropdown'; import notificationMenu from './notificationsDropdown';
import profileModal from '../userMenu/profileModal'; import profileModal from '../userMenu/profileModal';
import reportFlagModal from '../chat/reportFlagModal'; import reportFlagModal from '../chat/reportFlagModal';
@@ -733,7 +732,6 @@ import userDropdown from './userDropdown';
export default { export default {
components: { components: {
creatorIntro, creatorIntro,
InboxModal,
notificationMenu, notificationMenu,
profileModal, profileModal,
reportFlagModal, reportFlagModal,

View File

@@ -25,7 +25,7 @@ export default {
props: ['notification', 'canRemove'], props: ['notification', 'canRemove'],
methods: { methods: {
action () { action () {
this.$root.$emit('bv::show::modal', 'inbox-modal'); this.$router.push('/private-messages');
}, },
}, },
}; };

View File

@@ -139,7 +139,7 @@ import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints'; import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems'; import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
import CARD_RECEIVED from './notifications/cardReceived'; 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 NEW_CHAT_MESSAGE from './notifications/newChatMessage';
import WORLD_BOSS from './notifications/worldBoss'; import WORLD_BOSS from './notifications/worldBoss';
import VERIFY_USERNAME from './notifications/verifyUsername'; import VERIFY_USERNAME from './notifications/verifyUsername';

View File

@@ -33,7 +33,7 @@
<a <a
class="nav-link dropdown-item class="nav-link dropdown-item
dropdown-separated d-flex justify-content-between align-items-center" dropdown-separated d-flex justify-content-between align-items-center"
@click.prevent="showInbox()" @click.prevent="showPrivateMessages()"
> >
<div>{{ $t('messages') }}</div> <div>{{ $t('messages') }}</div>
<message-count <message-count
@@ -163,10 +163,15 @@ export default {
this.$store.state.avatarEditorOptions.subpage = subpage; this.$store.state.avatarEditorOptions.subpage = subpage;
this.$root.$emit('bv::show::modal', 'avatar-modal'); this.$root.$emit('bv::show::modal', 'avatar-modal');
}, },
showInbox () { showPrivateMessages () {
markPMSRead(this.user); markPMSRead(this.user);
axios.post('/api/v4/user/mark-pms-read'); 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) { showProfile (startingPage) {
this.$router.push({ name: startingPage }); this.$router.push({ name: startingPage });

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,9 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue'; import { storiesOf } from '@storybook/vue';
import { withKnobs, number } from '@storybook/addon-knobs';
import CountBadge from './countBadge.vue'; import CountBadge from './countBadge.vue';
storiesOf('Count Badge', module) const stories = storiesOf('Count Badge', module);
stories.addDecorator(withKnobs);
stories
.add('simple', () => ({ .add('simple', () => ({
components: { CountBadge }, components: { CountBadge },
template: ` template: `
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
<count-badge :count="count" :show="true"></count-badge> <count-badge :count="count" :show="true"></count-badge>
</div> </div>
`, `,
data () { props: {
return { count: {
count: 3, default: number('Count', 3),
}; },
}, },
})); }));

View File

@@ -2,14 +2,14 @@
<div class="popover-box"> <div class="popover-box">
<div <div
:id="containerId" :id="containerId"
class="clearfix" class="clearfix toggle-switch-outer"
> >
<div <div
v-if="label" v-if="label"
class="float-left toggle-switch-description" class="float-left toggle-switch-description"
:class="hoverText ? 'hasPopOver' : ''" :class="hoverText ? 'hasPopOver' : ''"
> >
{{ label }} <span>{{ label }}</span>
</div> </div>
<div class="toggle-switch float-left"> <div class="toggle-switch float-left">
<input <input
@@ -53,9 +53,7 @@
} }
.toggle-switch-description { .toggle-switch-description {
height: 20px; &.hasPopOver span {
&.hasPopOver {
border-bottom: 1px dashed $gray-200; border-bottom: 1px dashed $gray-200;
} }
} }

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>

View File

@@ -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>

View File

@@ -859,13 +859,12 @@ export default {
window.history.replaceState(null, null, ''); window.history.replaceState(null, null, '');
}, },
sendMessage () { sendMessage () {
this.$root.$emit('habitica::new-inbox-message', { this.$store.dispatch('user:newPrivateMessageTo', {
userIdToMessage: this.user._id, member: this.user,
displayName: this.user.profile.name,
username: this.user.auth.local.username,
backer: this.user.backer,
contributor: this.user.contributor,
}); });
this.$router.push('/private-messages');
this.$root.$emit('bv::hide::modal', 'profile');
}, },
getProgressDisplay () { getProgressDisplay () {
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives]; // let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];

View File

@@ -0,0 +1,867 @@
<template>
<div id="private-message">
<div class="floating-header-shadow"></div>
<div class="header-bar d-flex w-100">
<!-- changing w-25 would also need changes in .left-header.w-25 -->
<div class="d-flex w-25 left-header">
<div
v-once
class="mail-icon svg-icon"
v-html="icons.mail"
></div>
<h2
v-once
class="flex-fill text-center mail-icon-label"
>
{{ $t('messages') }}
</h2>
<div class="placeholder svg-icon">
<!-- placeholder -->
</div>
</div>
<div class="d-flex w-75 selected-conversion">
<face-avatar
v-if="selectedConversation.userStyles"
:member="selectedConversation.userStyles"
:class="selectedConversationFaceAvatarClass"
/>
<user-label
:backer="selectedConversation.backer"
:contributor="selectedConversation.contributor"
:name="selectedConversation.name"
:hide-tooltip="true"
/>
</div>
</div>
<div class="d-flex content">
<div class="w-25 sidebar d-flex flex-column">
<div class="disable-background">
<toggle-switch
:label="optTextSet.switchDescription"
:checked="this.user.inbox.optOut"
:hover-text="optTextSet.popoverText"
@change="toggleOpt()"
/>
</div>
<div class="search-section">
<b-form-input
v-model="search"
class="input-search"
:placeholder="$t('search')"
/>
</div>
<div
v-if="filtersConversations.length === 0"
class="empty-messages m-auto text-center empty-sidebar"
>
<div>
<div
v-once
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>
<div
v-if="filtersConversations.length > 0"
class="conversations"
>
<conversation-item
v-for="conversation in filtersConversations"
:key="conversation.key"
:active-key="selectedConversation.key"
:contributor="conversation.contributor"
:backer="conversation.backer"
:uuid="conversation.key"
:display-name="conversation.name"
:username="conversation.username"
:last-message-date="conversation.date"
:last-message-text="conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : ''"
@click="selectConversation(conversation.key)"
/>
</div>
</div>
<div class="w-75 messages-column d-flex flex-column align-items-center">
<div
v-if="!selectedConversation.key"
class="empty-messages full-height m-auto text-center"
>
<div
v-once
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4>{{ placeholderTexts.title }}</h4>
<p v-html="placeholderTexts.description"></p>
</div>
<div
v-if="selectedConversation.key && selectedConversationMessages.length === 0"
class="empty-messages full-height mt-auto text-center"
>
<avatar
v-if="selectedConversation.userStyles"
:member="selectedConversation.userStyles"
:avatar-only="true"
sprites-margin="0 0 0 -45px"
class="center-avatar"
/>
<h3>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</h3>
<p>{{ $t('beginningOfConversationReminder') }}</p>
</div>
<messageList
v-if="selectedConversation && selectedConversationMessages.length > 0"
ref="chatscroll"
class="message-scroll"
:chat="selectedConversationMessages"
:can-load-more="canLoadMore"
:is-loading="messagesLoading"
@message-removed="messageRemoved"
@triggerLoad="infiniteScrollTrigger"
/>
<div
v-if="user.inbox.optOut"
class="pm-disabled-caption text-center"
>
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
<p>{{ $t('PMDisabledCaptionText') }}</p>
</div>
<div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="new-message-row d-flex align-items-center"
>
<textarea
v-model="newMessage"
class="flex-fill"
:placeholder="$t('needsTextPlaceholder')"
maxlength="3000"
:class="{'has-content': newMessage !== ''}"
@keyup.ctrl.enter="sendPrivateMessage()"
></textarea>
</div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="sub-new-message-row d-flex"
>
<div
v-once
class="guidelines flex-fill"
v-html="$t('communityGuidelinesIntro')"
></div>
<button
class="btn btn-primary"
:class="{'disabled':newMessage === ''}"
@click="sendPrivateMessage()"
>
{{ $t('send') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/variables.scss';
$pmHeaderHeight: 56px;
// Content of Private Message should be always full-size (minus the toolbar/resting banner)
#private-message {
height: calc(100vh - #{$menuToolbarHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)); // css variable magic :), must be 0px, 0 alone won't work
.content {
flex: 1;
height: calc(100vh - #{$menuToolbarHeight} - #{$pmHeaderHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)
);
}
.disable-background {
.toggle-switch-description {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
}
.toggle-switch-outer {
display: flex;
}
}
.modal-body {
padding: 0rem;
}
.modal-content {
width: 66vw;
}
.modal-dialog {
margin: 10vh 15vw 0rem;
}
.modal-header {
padding: 1rem 0rem;
.close {
cursor: pointer;
margin: 0rem 1.5rem;
min-width: 0.75rem;
padding: 0rem;
width: 0.75rem;
}
}
.toggle-switch-description {
font-size: 14px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.43;
letter-spacing: normal;
color: $gray-50;
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
@import '~@/assets/scss/variables.scss';
$pmHeaderHeight: 56px;
$background: $white;
.header-bar {
height: 56px;
background-color: $white;
padding-left: 1.5rem;
padding-right: 1.5rem;
align-items: center;
.mail-icon {
width: 32px;
height: 24px;
object-fit: contain;
}
.mail-icon-label {
margin-bottom: 0;
}
.placeholder.svg-icon {
width: 32px;
}
.left-header.w-25 {
width: calc(25% - 2rem) !important;
}
}
.full-height {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.user-label {
margin-left: 12px;
}
.input-search {
background-repeat: no-repeat;
background-position: center left 16px;
background-size: 16px 16px;
background-image: url(~@/assets/svg/for-css/search_gray.svg) !important;
padding-left: 40px;
color: $gray-200 !important;
height: 40px;
}
.selected-conversion {
justify-content: center;
align-items: center;
}
#private-message {
background-color: $background;
position: relative;
}
.disable-background {
height: 44px;
background-color: $gray-600;
padding: 0.75rem 1.5rem;
}
.conversations {
max-height: 35rem;
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
.empty-messages {
h3, h4, p {
color: $gray-400;
margin: 0rem;
}
p {
font-size: 12px;
}
.envelope {
width: 30px;
margin: 0 auto 0.5rem;
}
}
.envelope {
color: $gray-500 !important;
margin: 0rem;
max-width: 2rem;
}
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.header-wrap {
padding: 0.5em;
h2 {
margin: 0;
line-height: 1;
}
}
.messagePreview {
display: block;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.messages-column {
padding: 0rem;
display: flex;
flex-direction: column;
.empty-messages, .message-scroll {
flex: 1;
}
}
.message-scroll {
overflow-x: hidden;
padding-top: 0.5rem;
@media (min-width: 992px) {
overflow-x: hidden;
overflow-y: scroll;
}
}
.new-message-row {
width: 100%;
padding-left: 1.5rem;
padding-top: 1.5rem;
padding-right: 1.5rem;
textarea {
height: 5.5rem;
display: inline-block;
vertical-align: bottom;
border-radius: 2px;
z-index: 5;
border: solid 1px $gray-400;
opacity: 0.64;
background-color: $gray-500;
&:focus, &.has-content {
opacity: 1;
background-color: $white;
}
}
}
.sub-new-message-row {
padding: 1rem 1.5rem 1.5rem;
.guidelines {
height: 32px;
font-size: 12px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
color: $gray-200;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
button {
height: 40px;
border-radius: 2px;
margin-left: 1.5rem;
&.disabled {
cursor: default;
pointer-events: none;
opacity: 0.64;
background-color: $gray-500;
}
}
}
.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;
}
}
.sidebar {
background-color: $gray-700;
min-height: 540px;
max-width: 330px;
padding: 0;
border-bottom-left-radius: 8px;
.search-section {
padding: 1rem 1.5rem;
border-bottom: 1px solid $gray-500;
}
}
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.to-form input {
width: 60%;
display: inline-block;
margin-left: 1em;
}
.empty-sidebar {
display: flex;
align-items: center;
}
.floating-message-input {
background: $background;
position: fixed;
bottom: 0;
}
.floating-header-shadow {
position: absolute;
top: 0;
width: 100%;
height: 56px;
right: 0;
z-index: 1;
pointer-events: none;
box-shadow: 0 3px 12px 0 rgba(26, 24, 29, 0.24);
}
.center-avatar {
margin: 0 auto;
}
</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 userLabel from '@/components/userLabel';
import messageList from '@/components/messages/messageList';
import messageIcon from '@/assets/svg/message.svg';
import mail from '@/assets/svg/mail.svg';
import conversationItem from '@/components/messages/conversationItem';
import faceAvatar from '@/components/faceAvatar';
import Avatar from '@/components/avatar';
export default {
components: {
Avatar,
messageList,
toggleSwitch,
conversationItem,
userLabel,
faceAvatar,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper],
data () {
return {
icons: Object.freeze({
messageIcon,
mail,
}),
displayCreate: true,
selectedConversation: {},
search: '',
newMessage: '',
showPopover: false,
messages: [],
messagesByConversation: {}, // cache {uuid: []}
loadedConversations: [],
loaded: false,
messagesLoading: false,
initiatedConversation: null,
updateConversationsCounter: 0,
};
},
async mounted () {
this.$root.$on('pm::refresh', async () => {
await this.reload();
this.selectConversation(this.loadedConversations[0].uuid, true);
});
await this.reload();
const data = this.$store.state.privateMessageOptions;
if (data && data.userIdToMessage) {
this.initiatedConversation = {
uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
userStyles: data.userStyles,
};
this.$store.state.privateMessageOptions = {};
this.selectConversation(this.initiatedConversation.uuid);
}
},
destroyed () {
this.$root.$off('habitica::new-private-message');
},
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,
backer: this.initiatedConversation.backer,
userStyles: this.initiatedConversation.userStyles,
id: '',
text: '',
timestamp: new Date(),
}];
}
// Create conversation objects
const convos = [];
for (const key in inboxGroup) {
if (Object.prototype.hasOwnProperty.call(inboxGroup, key)) {
const recentMessage = inboxGroup[key][0];
const convoModel = {
key: recentMessage.uuid,
name: recentMessage.user,
// Handles case where from user sent the only message
// or the to user sent the only message
username: recentMessage.username,
date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
contributor: recentMessage.contributor,
userStyles: recentMessage.userStyles,
backer: recentMessage.backer,
canLoadMore: false,
page: 0,
};
convos.push(convoModel);
}
}
return convos;
},
// Separate from selectedConversation which is not computed
// so messages don't update automatically
/* eslint-disable vue/no-side-effects-in-computed-properties */
selectedConversationMessages () {
// Vue-subscribe to changes
const subscribeToUpdate = this.messagesLoading || this.updateConversationsCounter > -1;
const selectedConversationKey = this.selectedConversation.key;
const selectedConversation = this.messagesByConversation[selectedConversationKey];
this.messages = selectedConversation || [];
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
if (subscribeToUpdate) {
return ordered;
}
return [];
},
filtersConversations () {
// Vue-subscribe to changes
const subscribeToUpdate = this.updateConversationsCounter > -1;
const filtered = subscribeToUpdate && !this.search
? this.conversations
/* eslint-disable max-len */
: 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('PMDisabled'),
popoverText: this.$t('PMEnabledOptPopoverText'),
};
}
return {
switchDescription: this.$t('PMDisabled'),
popoverText: this.$t('PMDisabledOptPopoverText'),
};
},
selectedConversationFaceAvatarClass () {
if (this.selectedConversation && this.selectedConversation.contributor) {
return `tier${this.selectedConversation.contributor.level}`;
}
return '';
},
},
methods: {
async reload () {
this.loaded = false;
const conversationRes = await axios.get('/api/v4/inbox/conversations');
this.loadedConversations = conversationRes.data.data;
this.selectedConversation = {};
this.loaded = true;
},
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, forceLoadMessage = false) {
const convoFound = this.conversations.find(conversation => conversation.key === key);
this.selectedConversation = convoFound || {};
if (!this.messagesByConversation[this.selectedConversation.key] || forceLoadMessage) {
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.updateConversationsCounter += 1;
});
this.newMessage = '';
},
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;
// use local vars if the loading takes longer
// and the user switches the conversation while loading
const conversationKey = this.selectedConversation.key;
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${conversationKey}&page=${this.selectedConversation.page}`;
const res = await axios.get(requestUrl);
const loadedMessages = res.data.data;
this.messagesByConversation[conversationKey] = this.messagesByConversation[conversationKey] || [];
const loadedMessagesToAdd = loadedMessages
.filter(m => this.messagesByConversation[conversationKey].findIndex(mI => mI.id === m.id) === -1);
this.messagesByConversation[conversationKey].push(...loadedMessagesToAdd);
// only show the load more Button if the max count was returned
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
this.messagesLoading = false;
},
},
};
</script>

View File

@@ -75,6 +75,8 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation'); const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing'); const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages');
// Challenges // Challenges
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index'); const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges'); const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges');
@@ -193,6 +195,7 @@ const router = new VueRouter({
}, },
], ],
}, },
{ path: '/private-messages', name: 'privateMessages', component: MessagesIndex },
{ {
name: 'challenges', name: 'challenges',
path: '/challenges', path: '/challenges',

View File

@@ -160,3 +160,65 @@ export async function userLookup (store, params) {
} }
return response; return response;
} }
export function block (store, params) {
store.state.user.data.inbox.blocks.push(params.uuid);
return axios.post(`/api/v4/user/block/${params.uuid}`);
}
export function unblock (store, params) {
const index = store.state.user.data.inbox.blocks.indexOf(params.uuid);
store.state.user.data.inbox.blocks.splice(index, 1);
return axios.post(`/api/v4/user/block/${params.uuid}`);
}
export function newPrivateMessageTo (store, params) {
const { member } = params;
const userStyles = {};
userStyles.items = { gear: {} };
if (member.items) {
userStyles.items.gear = {};
userStyles.items.gear.costume = { ...member.items.gear.costume };
userStyles.items.gear.equipped = { ...member.items.gear.equipped };
userStyles.items.currentMount = member.items.currentMount;
userStyles.items.currentPet = member.items.currentPet;
}
if (member.preferences) {
userStyles.preferences = {};
if (member.preferences.style) userStyles.preferences.style = member.preferences.style;
userStyles.preferences.hair = member.preferences.hair;
userStyles.preferences.skin = member.preferences.skin;
userStyles.preferences.shirt = member.preferences.shirt;
userStyles.preferences.chair = member.preferences.chair;
userStyles.preferences.size = member.preferences.size;
userStyles.preferences.chair = member.preferences.chair;
userStyles.preferences.background = member.preferences.background;
userStyles.preferences.costume = member.preferences.costume;
}
if (member.stats) {
userStyles.stats = {};
userStyles.stats.class = member.stats.class;
if (member.stats.buffs) {
userStyles.stats.buffs = {
seafoam: member.stats.buffs.seafoam,
shinySeed: member.stats.buffs.shinySeed,
spookySparkles: member.stats.buffs.spookySparkles,
snowball: member.stats.buffs.snowball,
};
}
}
store.state.privateMessageOptions = {
userIdToMessage: member._id,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
userStyles,
};
}

View File

@@ -127,6 +127,13 @@ export default function () {
equipmentDrawerOpen: true, equipmentDrawerOpen: true,
groupPlans: [], groupPlans: [],
isRunningYesterdailies: false, isRunningYesterdailies: false,
privateMessageOptions: {
userIdToMessage: '',
displayName: '',
username: '',
backer: {},
contributor: {},
},
}, },
}); });

View File

@@ -0,0 +1,9 @@
const path = require('path');
module.exports = {
resolve: {
alias: {
'@': path.join(__dirname, 'src'),
}
},
};

View File

@@ -138,6 +138,7 @@
"PMPlaceholderDescription": "Select a conversation on the left", "PMPlaceholderDescription": "Select a conversation on the left",
"PMPlaceholderTitleRevoked": "Your chat privileges have been revoked", "PMPlaceholderTitleRevoked": "Your chat privileges have been revoked",
"PMReceive": "Receive Private Messages", "PMReceive": "Receive Private Messages",
"PMDisabled": "Disable Private Messages",
"PMEnabledOptPopoverText": "Private Messages are enabled. Users can contact you via your profile.", "PMEnabledOptPopoverText": "Private Messages are enabled. Users can contact you via your profile.",
"PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.", "PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.",
"PMDisabledCaptionTitle": "Private Messages are disabled", "PMDisabledCaptionTitle": "Private Messages are disabled",

View File

@@ -68,7 +68,8 @@
"notificationsRequired": "Notification ids are required.", "notificationsRequired": "Notification ids are required.",
"unallocatedStatsPoints": "You have <span class=\"notification-bold-blue\"><%= points %> unallocated Stat Points</span>", "unallocatedStatsPoints": "You have <span class=\"notification-bold-blue\"><%= points %> unallocated Stat Points</span>",
"beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!", "beginningOfConversation": "This is the beginning of your conversation with <%= userName %>.",
"beginningOfConversationReminder": "Remember to be kind, respectful, and follow the Community Guidelines!",
"messageDeletedUser": "Sorry, this user has deleted their account.", "messageDeletedUser": "Sorry, this user has deleted their account.",
"messageMissingDisplayName": "Missing display name.", "messageMissingDisplayName": "Missing display name.",

View File

@@ -1,9 +1,10 @@
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import apiError from '../../libs/apiError'; import apiError from '../../libs/apiError';
import * as inboxLib from '../../libs/inbox';
import { import {
NotFound, NotFound,
} from '../../libs/errors'; } from '../../libs/errors';
import { listConversations } from '../../libs/inbox/conversation.methods';
import { clearPMs, deleteMessage, getUserInbox } from '../../libs/inbox';
const api = {}; const api = {};
@@ -40,7 +41,7 @@ api.deleteMessage = {
const { messageId } = req.params; const { messageId } = req.params;
const { user } = res.locals; const { user } = res.locals;
const deleted = await inboxLib.deleteMessage(user, messageId); const deleted = await deleteMessage(user, messageId);
if (!deleted) throw new NotFound(res.t('messageGroupChatNotFound')); if (!deleted) throw new NotFound(res.t('messageGroupChatNotFound'));
res.respond(200); res.respond(200);
@@ -66,7 +67,7 @@ api.clearMessages = {
async handler (req, res) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
await inboxLib.clearPMs(user); await clearPMs(user);
res.respond(200, {}); res.respond(200, {});
}, },
@@ -101,7 +102,7 @@ api.conversations = {
async handler (req, res) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
const result = await inboxLib.listConversations(user); const result = await listConversations(user);
res.respond(200, result); res.respond(200, result);
}, },
@@ -128,7 +129,7 @@ api.getInboxMessages = {
const { page } = req.query; const { page } = req.query;
const { conversation } = req.query; const { conversation } = req.query;
const userInbox = await inboxLib.getUserInbox(user, { const userInbox = await getUserInbox(user, {
page, conversation, mapProps: true, page, conversation, mapProps: true,
}); });

View File

@@ -0,0 +1,121 @@
import { inboxModel as Inbox, setUserStyles } from '../../models/message';
import { model as User } from '../../models/user';
/**
* Get the users for conversations
* 1. Get the user data of last sent message by conversation
* 2. If the target user hasn't replied yet ( 'sent:true' ) , list user data by users directly
* @param owner
* @param users
* @returns {Promise<void>}
*/
async function usersMapByConversations (owner, users) {
const query = Inbox
.aggregate([
{
$match: {
ownerId: owner._id,
uuid: { $in: users },
sent: false, // only messages the other user sent to you
},
},
{
$group: {
_id: '$uuid',
userStyles: { $last: '$userStyles' },
contributor: { $last: '$contributor' },
backer: { $last: '$backer' },
},
},
]);
const usersAr = await query.exec();
const usersMap = {};
for (const usr of usersAr) {
usersMap[usr._id] = usr;
}
// if a conversation doesn't have a response of the chat-partner,
// those won't be listed by the query above
const usersStillNeedToBeLoaded = users.filter(userId => !usersMap[userId]);
if (usersStillNeedToBeLoaded.length > 0) {
const usersQuery = {
_id: { $in: usersStillNeedToBeLoaded },
};
const loadedUsers = await User.find(usersQuery, {
_id: 1,
contributor: 1,
backer: 1,
items: 1,
preferences: 1,
stats: 1,
}).exec();
for (const usr of loadedUsers) {
const loadedUserConversation = {
_id: usr._id,
backer: usr.backer,
contributor: usr.contributor,
};
// map user values to conversation properties
setUserStyles(loadedUserConversation, usr);
usersMap[usr._id] = loadedUserConversation;
}
}
return usersMap;
}
export async function listConversations (owner) {
// group messages by user owned by logged-in user
const query = Inbox
.aggregate([
{
$match: {
ownerId: owner._id,
},
},
{
$group: {
_id: '$uuid',
user: { $last: '$user' },
username: { $last: '$username' },
timestamp: { $last: '$timestamp' },
text: { $last: '$text' },
count: { $sum: 1 },
},
},
{ $sort: { timestamp: -1 } }, // sort by latest message
]);
const conversationsList = await query.exec();
const userIdList = conversationsList.map(c => c._id);
// get user-info based on conversations
const usersMap = await usersMapByConversations(owner, userIdList);
const conversations = conversationsList.map(res => {
const uuid = res._id;
const conversation = {
uuid,
...res,
};
if (usersMap[uuid]) {
conversation.userStyles = usersMap[uuid].userStyles;
conversation.contributor = usersMap[uuid].contributor;
conversation.backer = usersMap[uuid].backer;
}
return conversation;
});
return conversations;
}

View File

@@ -80,74 +80,6 @@ export async function getUserInbox (user, options = {
return messagesObj; return messagesObj;
} }
async function usersMapByConversations (owner, users) {
const query = Inbox
.aggregate([
{
$match: {
ownerId: owner._id,
uuid: { $in: users },
},
},
{
$group: {
_id: '$uuid',
userStyles: { $last: '$userStyles' },
contributor: { $last: '$contributor' },
},
},
]);
const usersAr = await query.exec();
const usersMap = {};
for (const usr of usersAr) {
usersMap[usr._id] = usr;
}
return usersMap;
}
export async function listConversations (owner) {
// group messages by user owned by logged-in user
const query = Inbox
.aggregate([
{
$match: {
ownerId: owner._id,
},
},
{
$group: {
_id: '$uuid',
user: { $last: '$user' },
username: { $last: '$username' },
timestamp: { $last: '$timestamp' },
text: { $last: '$text' },
count: { $sum: 1 },
},
},
{ $sort: { timestamp: -1 } }, // sort by latest message
]);
const conversationsList = await query.exec();
const userIdList = conversationsList.map(c => c._id);
// get user-info based on conversations
const usersMap = await usersMapByConversations(owner, userIdList);
const conversations = conversationsList.map(res => ({
uuid: res._id,
...res,
userStyles: usersMap[res._id].userStyles,
contributor: usersMap[res._id].contributor,
}));
return conversations;
}
export async function getUserInboxMessage (user, messageId) { export async function getUserInboxMessage (user, messageId) {
return Inbox.findOne({ ownerId: user._id, _id: messageId }).exec(); return Inbox.findOne({ ownerId: user._id, _id: messageId }).exec();
} }

View File

@@ -107,7 +107,10 @@ export function setUserStyles (newMessage, user) {
newMessage.contributor = contributorCopy; newMessage.contributor = contributorCopy;
newMessage.userStyles = userStyles; newMessage.userStyles = userStyles;
newMessage.markModified('userStyles contributor');
if (newMessage.markModified) {
newMessage.markModified('userStyles contributor');
}
} }
export function messageDefaults (msg, user, client, flagCount = 0, info = {}) { export function messageDefaults (msg, user, client, flagCount = 0, info = {}) {