mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 05:37:22 +01:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ yarn.lock
|
||||
.elasticbeanstalk/*
|
||||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
|
||||
# webstorm fake webpack for path intellisense
|
||||
webpack.webstorm.config
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { configure } from '@storybook/vue';
|
||||
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$/);
|
||||
|
||||
@@ -8,4 +39,6 @@ function loadStories () {
|
||||
req.keys().forEach(filename => req(filename));
|
||||
}
|
||||
|
||||
Vue.use(StoreModule);
|
||||
|
||||
configure(loadStories, module);
|
||||
|
||||
75
website/client/config/storybook/mock.data.js
Normal file
75
website/client/config/storybook/mock.data.js
Normal 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,
|
||||
},
|
||||
};
|
||||
41
website/client/package-lock.json
generated
41
website/client/package-lock.json
generated
@@ -11845,6 +11845,11 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -12090,6 +12095,17 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.0.tgz",
|
||||
@@ -13101,6 +13117,21 @@
|
||||
"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": {
|
||||
"version": "5.2.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.23.2",
|
||||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"vue-mugen-scroll": "^0.2.6",
|
||||
"vue-router": "^3.0.6",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vue2-perfect-scrollbar": "^1.2.1",
|
||||
"vuedraggable": "^2.23.1",
|
||||
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
|
||||
"webpack": "^4.41.5"
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
</div>
|
||||
<div
|
||||
id="app"
|
||||
:class="{'casting-spell': castingSpell}"
|
||||
:class="{
|
||||
'casting-spell': castingSpell,
|
||||
'resting': showRestingBanner
|
||||
}"
|
||||
>
|
||||
<banned-account-modal />
|
||||
<amazon-payments-modal v-if="!isStaticPage" />
|
||||
@@ -66,7 +69,10 @@
|
||||
</div>
|
||||
<notifications-display />
|
||||
<app-menu />
|
||||
<div class="container-fluid">
|
||||
<div
|
||||
class="container-fluid"
|
||||
:class="{'no-margin': noMargin}"
|
||||
>
|
||||
<app-header />
|
||||
<buyModal
|
||||
:item="selectedItemToBuy || {}"
|
||||
@@ -83,7 +89,7 @@
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<app-footer />
|
||||
<app-footer v-if="!hideFooter" />
|
||||
<audio
|
||||
id="sound"
|
||||
ref="sound"
|
||||
@@ -97,13 +103,20 @@
|
||||
|
||||
<style lang='scss' scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
#app {
|
||||
height: calc(100% - 56px); /* 56px is the menu */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
|
||||
&.resting {
|
||||
--banner-resting-height: #{$restingToolbarHeight};
|
||||
}
|
||||
|
||||
&.giftingBanner {
|
||||
--banner-gifting-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#loading-screen-inapp {
|
||||
@@ -148,6 +161,13 @@
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.no-margin {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.notification {
|
||||
border-radius: 1000px;
|
||||
background-color: $green-10;
|
||||
@@ -160,7 +180,7 @@
|
||||
|
||||
.resting-banner {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
height: $restingToolbarHeight;
|
||||
background-color: $blue-10;
|
||||
top: 0;
|
||||
z-index: 1300;
|
||||
@@ -302,7 +322,13 @@ export default {
|
||||
return this.$t(`tip${tipNumber}`);
|
||||
},
|
||||
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 () {
|
||||
|
||||
@@ -7,3 +7,6 @@ $npc_quests_flavor: 'nye';
|
||||
$npc_seasonal_flavor: 'nye';
|
||||
$npc_timetravelers_flavor: 'winter';
|
||||
$npc_tavern_flavor: 'nye';
|
||||
|
||||
$restingToolbarHeight: 40px;
|
||||
$menuToolbarHeight: 56px;
|
||||
|
||||
3
website/client/src/assets/svg/for-css/search_gray.svg
Normal file
3
website/client/src/assets/svg/for-css/search_gray.svg
Normal 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 |
7
website/client/src/assets/svg/mail.svg
Normal file
7
website/client/src/assets/svg/mail.svg
Normal 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 |
@@ -5,7 +5,7 @@
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="!inbox && user.contributor.admin && msg.flagCount"
|
||||
v-if="user.contributor.admin && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
{{ flagCountDescription }}
|
||||
@@ -27,8 +27,7 @@
|
||||
class="mr-1"
|
||||
>•</span>
|
||||
<span
|
||||
v-b-tooltip
|
||||
:title="msg.timestamp | date"
|
||||
v-b-tooltip.hover="messageDate"
|
||||
>{{ msg.timestamp | timeAgo }} </span>
|
||||
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
|
||||
</p>
|
||||
@@ -37,21 +36,12 @@
|
||||
class="text"
|
||||
v-html="atHighlight(parseMarkdown(msg.text))"
|
||||
></div>
|
||||
<div
|
||||
v-if="isMessageReported && (inbox === true)"
|
||||
class="reported"
|
||||
>
|
||||
<span v-once>{{ $t('reportedMessage') }}</span>
|
||||
<br>
|
||||
<span v-once>{{ $t('canDeleteNow') }}</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div
|
||||
v-if="msg.id"
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
v-if="!inbox"
|
||||
class="action d-flex align-items-center"
|
||||
@click="copyAsTodo(msg)"
|
||||
>
|
||||
@@ -62,7 +52,7 @@
|
||||
<div>{{ $t('copyAsTodo') }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system'))
|
||||
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
|
||||
&& (!isMessageReported || user.contributor.admin)"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
@@ -77,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="msg.uuid === user._id || inbox || user.contributor.admin"
|
||||
v-if="msg.uuid === user._id || user.contributor.admin"
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
>
|
||||
@@ -91,7 +81,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!inbox"
|
||||
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
|
||||
class="ml-auto d-flex"
|
||||
>
|
||||
@@ -121,7 +110,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!msg.likes[user._id] && !inbox">{{ $t('like') }}</span>
|
||||
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,15 +194,9 @@
|
||||
color: $purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
.reported {
|
||||
margin-top: 18px;
|
||||
color: $red-50;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
@@ -244,10 +227,6 @@ export default {
|
||||
},
|
||||
props: {
|
||||
msg: {},
|
||||
inbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupId: {},
|
||||
},
|
||||
data () {
|
||||
@@ -311,6 +290,10 @@ export default {
|
||||
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
|
||||
return 'Message hidden (shadow-muted)';
|
||||
},
|
||||
messageDate () {
|
||||
const date = moment(this.msg.timestamp).toDate();
|
||||
return date.toString();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
const links = this.$refs.markdownContainer.getElementsByTagName('a');
|
||||
@@ -372,11 +355,6 @@ export default {
|
||||
const message = this.msg;
|
||||
this.$emit('message-removed', message);
|
||||
|
||||
if (this.inbox) {
|
||||
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$store.dispatch('chat:deleteChat', {
|
||||
groupId: this.groupId,
|
||||
chatId: message.id,
|
||||
|
||||
@@ -35,13 +35,11 @@
|
||||
v-for="msg in messages"
|
||||
v-if="chat && canViewFlag(msg)"
|
||||
:key="msg.id"
|
||||
:class="{row: inbox}"
|
||||
>
|
||||
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-if="user._id !== msg.uuid"
|
||||
class="d-flex"
|
||||
:class="{'flex-grow-1': inbox}"
|
||||
>
|
||||
<avatar
|
||||
v-if="msg.userStyles
|
||||
@@ -51,16 +49,13 @@
|
||||
:avatar-only="true"
|
||||
:override-top-padding="'14px'"
|
||||
:hide-class-badge="true"
|
||||
:class="{'inbox-avatar-left': inbox}"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'col-10': inbox}"
|
||||
>
|
||||
<chat-card
|
||||
:msg="msg"
|
||||
:inbox="inbox"
|
||||
:group-id="groupId"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@@ -72,15 +67,12 @@
|
||||
<div
|
||||
v-if="user._id === msg.uuid"
|
||||
class="d-flex"
|
||||
:class="{'flex-grow-1': inbox}"
|
||||
>
|
||||
<div
|
||||
class="card"
|
||||
:class="{'col-10': inbox}"
|
||||
>
|
||||
<chat-card
|
||||
:msg="msg"
|
||||
:inbox="inbox"
|
||||
:group-id="groupId"
|
||||
@message-liked="messageLiked"
|
||||
@message-removed="messageRemoved"
|
||||
@@ -95,7 +87,6 @@
|
||||
:avatar-only="true"
|
||||
:hide-class-badge="true"
|
||||
:override-top-padding="'14px'"
|
||||
:class="{'inbox-avatar-right': inbox}"
|
||||
@click.native="showMemberModal(msg.uuid)"
|
||||
/>
|
||||
</div>
|
||||
@@ -144,16 +135,6 @@
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.inbox-avatar-left {
|
||||
margin-left: -1rem;
|
||||
margin-right: 2.5rem;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.inbox-avatar-right {
|
||||
margin-left: -3.5rem;
|
||||
}
|
||||
|
||||
.hr {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
@@ -209,10 +190,6 @@ export default {
|
||||
},
|
||||
props: {
|
||||
chat: {},
|
||||
inbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupType: {},
|
||||
groupId: {},
|
||||
groupName: {},
|
||||
@@ -260,12 +237,6 @@ export default {
|
||||
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
|
||||
// disable scroll
|
||||
container.style.overflowY = 'hidden';
|
||||
|
||||
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
|
||||
if (canLoadMore) {
|
||||
await this.$emit('triggerLoad');
|
||||
this.handleScrollBack = true;
|
||||
}
|
||||
},
|
||||
canViewFlag (message) {
|
||||
if (message.uuid === this.user._id) return true;
|
||||
@@ -380,11 +351,6 @@ export default {
|
||||
this.chat.splice(chatIndex, 1, message);
|
||||
},
|
||||
messageRemoved (message) {
|
||||
if (this.inbox) {
|
||||
this.$emit('message-removed', message);
|
||||
return;
|
||||
}
|
||||
|
||||
const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id);
|
||||
this.chat.splice(chatIndex, 1);
|
||||
},
|
||||
|
||||
47
website/client/src/components/faceAvatar.stories.js
Normal file
47
website/client/src/components/faceAvatar.stories.js
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
154
website/client/src/components/faceAvatar.vue
Normal file
154
website/client/src/components/faceAvatar.vue
Normal 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>
|
||||
@@ -536,13 +536,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
sendMessage (member) {
|
||||
this.$root.$emit('habitica::new-inbox-message', {
|
||||
userIdToMessage: member._id,
|
||||
displayName: member.profile.name,
|
||||
username: member.auth.local.username,
|
||||
backer: member.backer,
|
||||
contributor: member.contributor,
|
||||
this.$store.dispatch('user:newPrivateMessageTo', {
|
||||
member,
|
||||
});
|
||||
|
||||
this.$root.$emit('bv::hide::modal', 'members-modal');
|
||||
this.$router.push('/private-messages');
|
||||
},
|
||||
async searchMembers (searchTerm = '') {
|
||||
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div
|
||||
id="app-header"
|
||||
class="row"
|
||||
:class="{'hide-header': $route.name === 'groupPlan'}"
|
||||
:class="{'hide-header': hideHeader}"
|
||||
>
|
||||
<members-modal :hide-badge="true" />
|
||||
<member-details
|
||||
@@ -171,6 +171,9 @@ export default {
|
||||
sortedPartyMembers () {
|
||||
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
|
||||
},
|
||||
hideHeader () {
|
||||
return ['groupPlan', 'privateMessages'].includes(this.$route.name);
|
||||
},
|
||||
},
|
||||
created () {
|
||||
if (this.user.party && this.user.party._id) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<inbox-modal />
|
||||
<creator-intro />
|
||||
<profileModal />
|
||||
<report-flag-modal />
|
||||
@@ -408,6 +407,7 @@
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
@import '~@/assets/scss/utils.scss';
|
||||
@import '~@/assets/scss/variables.scss';
|
||||
|
||||
@media only screen and (max-width: 1200px) {
|
||||
.chevron {
|
||||
@@ -438,7 +438,7 @@
|
||||
}
|
||||
|
||||
.topbar {
|
||||
max-height: 56px;
|
||||
max-height: $menuToolbarHeight;
|
||||
|
||||
.currency-tray {
|
||||
margin-left: auto;
|
||||
@@ -721,7 +721,6 @@ import chevronDownIcon from '@/assets/svg/chevron-down.svg';
|
||||
import logo from '@/assets/svg/logo.svg';
|
||||
|
||||
import creatorIntro from '../creatorIntro';
|
||||
import InboxModal from '../userMenu/inbox.vue';
|
||||
import notificationMenu from './notificationsDropdown';
|
||||
import profileModal from '../userMenu/profileModal';
|
||||
import reportFlagModal from '../chat/reportFlagModal';
|
||||
@@ -733,7 +732,6 @@ import userDropdown from './userDropdown';
|
||||
export default {
|
||||
components: {
|
||||
creatorIntro,
|
||||
InboxModal,
|
||||
notificationMenu,
|
||||
profileModal,
|
||||
reportFlagModal,
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
props: ['notification', 'canRemove'],
|
||||
methods: {
|
||||
action () {
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
this.$router.push('/private-messages');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -139,7 +139,7 @@ import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
|
||||
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
|
||||
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
|
||||
import CARD_RECEIVED from './notifications/cardReceived';
|
||||
import NEW_INBOX_MESSAGE from './notifications/newInboxMessage';
|
||||
import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage';
|
||||
import NEW_CHAT_MESSAGE from './notifications/newChatMessage';
|
||||
import WORLD_BOSS from './notifications/worldBoss';
|
||||
import VERIFY_USERNAME from './notifications/verifyUsername';
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<a
|
||||
class="nav-link dropdown-item
|
||||
dropdown-separated d-flex justify-content-between align-items-center"
|
||||
@click.prevent="showInbox()"
|
||||
@click.prevent="showPrivateMessages()"
|
||||
>
|
||||
<div>{{ $t('messages') }}</div>
|
||||
<message-count
|
||||
@@ -163,10 +163,15 @@ export default {
|
||||
this.$store.state.avatarEditorOptions.subpage = subpage;
|
||||
this.$root.$emit('bv::show::modal', 'avatar-modal');
|
||||
},
|
||||
showInbox () {
|
||||
showPrivateMessages () {
|
||||
markPMSRead(this.user);
|
||||
axios.post('/api/v4/user/mark-pms-read');
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
|
||||
if (this.$router.history.current.name === 'privateMessages') {
|
||||
this.$root.$emit('pm::refresh');
|
||||
} else {
|
||||
this.$router.push('/private-messages');
|
||||
}
|
||||
},
|
||||
showProfile (startingPage) {
|
||||
this.$router.push({ name: startingPage });
|
||||
|
||||
235
website/client/src/components/messages/conversationItem.vue
Normal file
235
website/client/src/components/messages/conversationItem.vue
Normal 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>
|
||||
216
website/client/src/components/messages/messageCard.vue
Normal file
216
website/client/src/components/messages/messageCard.vue
Normal 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 }} </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>
|
||||
400
website/client/src/components/messages/messageList.vue
Normal file
400
website/client/src/components/messages/messageList.vue
Normal 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>
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { storiesOf } from '@storybook/vue';
|
||||
import { withKnobs, number } from '@storybook/addon-knobs';
|
||||
|
||||
import CountBadge from './countBadge.vue';
|
||||
|
||||
storiesOf('Count Badge', module)
|
||||
const stories = storiesOf('Count Badge', module);
|
||||
|
||||
stories.addDecorator(withKnobs);
|
||||
|
||||
stories
|
||||
.add('simple', () => ({
|
||||
components: { CountBadge },
|
||||
template: `
|
||||
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
|
||||
<count-badge :count="count" :show="true"></count-badge>
|
||||
</div>
|
||||
`,
|
||||
data () {
|
||||
return {
|
||||
count: 3,
|
||||
};
|
||||
props: {
|
||||
count: {
|
||||
default: number('Count', 3),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div class="popover-box">
|
||||
<div
|
||||
:id="containerId"
|
||||
class="clearfix"
|
||||
class="clearfix toggle-switch-outer"
|
||||
>
|
||||
<div
|
||||
v-if="label"
|
||||
class="float-left toggle-switch-description"
|
||||
:class="hoverText ? 'hasPopOver' : ''"
|
||||
>
|
||||
{{ label }}
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
<div class="toggle-switch float-left">
|
||||
<input
|
||||
@@ -53,9 +53,7 @@
|
||||
}
|
||||
|
||||
.toggle-switch-description {
|
||||
height: 20px;
|
||||
|
||||
&.hasPopOver {
|
||||
&.hasPopOver span {
|
||||
border-bottom: 1px dashed $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
115
website/client/src/components/userLabel.vue
Normal file
115
website/client/src/components/userLabel.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="displayName"
|
||||
v-b-tooltip.hover.top="tierTitle"
|
||||
class="user-label"
|
||||
:class="levelStyle()"
|
||||
>
|
||||
{{ displayName }}
|
||||
<div
|
||||
class="svg-icon"
|
||||
v-html="tierIcon()"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.user-label.no-tier {
|
||||
color: $gray-50;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
|
||||
.svg-icon {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import achievementsLib from '@/../../common/script/libs/achievements';
|
||||
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
|
||||
import tier1 from '@/assets/svg/tier-1.svg';
|
||||
import tier2 from '@/assets/svg/tier-2.svg';
|
||||
import tier3 from '@/assets/svg/tier-3.svg';
|
||||
import tier4 from '@/assets/svg/tier-4.svg';
|
||||
import tier5 from '@/assets/svg/tier-5.svg';
|
||||
import tier6 from '@/assets/svg/tier-6.svg';
|
||||
import tier7 from '@/assets/svg/tier-7.svg';
|
||||
import tier8 from '@/assets/svg/tier-mod.svg';
|
||||
import tier9 from '@/assets/svg/tier-staff.svg';
|
||||
import tierNPC from '@/assets/svg/tier-npc.svg';
|
||||
|
||||
export default {
|
||||
mixins: [styleHelper],
|
||||
props: ['user', 'name', 'backer', 'contributor', 'hideTooltip'],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
tier1,
|
||||
tier2,
|
||||
tier3,
|
||||
tier4,
|
||||
tier5,
|
||||
tier6,
|
||||
tier7,
|
||||
tier8,
|
||||
tier9,
|
||||
tierNPC,
|
||||
}),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayName () {
|
||||
if (this.name) {
|
||||
return this.name;
|
||||
}
|
||||
if (this.user && this.user.profile) {
|
||||
return this.user.profile.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
level () {
|
||||
if (this.contributor) {
|
||||
return this.contributor.level;
|
||||
} if (this.user && this.user.contributor) {
|
||||
return this.user.contributor.level;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
isNPC () {
|
||||
if (this.backer) {
|
||||
return this.backer.level;
|
||||
} if (this.user && this.user.backer) {
|
||||
return this.user.backer.level;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
tierIcon () {
|
||||
if (this.isNPC) {
|
||||
return this.icons.tierNPC;
|
||||
}
|
||||
return this.icons[`tier${this.level}`];
|
||||
},
|
||||
tierTitle () {
|
||||
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
|
||||
},
|
||||
levelStyle () {
|
||||
return this.userLevelStyleFromLevel(this.level, this.isNPC);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,698 +0,0 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="inbox-modal"
|
||||
title
|
||||
:hide-footer="true"
|
||||
size="lg"
|
||||
@shown="onModalShown"
|
||||
@hide="onModalHide"
|
||||
>
|
||||
<div
|
||||
slot="modal-header"
|
||||
class="header-wrap container align-items-center"
|
||||
>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-2">
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h2
|
||||
v-once
|
||||
class="text-center"
|
||||
>
|
||||
{{ $t('messages') }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 offset-3">
|
||||
<toggle-switch
|
||||
class="float-right"
|
||||
:label="optTextSet.switchDescription"
|
||||
:checked="!user.inbox.optOut"
|
||||
:hover-text="optTextSet.popoverText"
|
||||
@change="toggleOpt()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<div class="close">
|
||||
<span
|
||||
class="svg-icon inline icon-10"
|
||||
aria-hidden="true"
|
||||
@click="close()"
|
||||
v-html="icons.svgClose"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4 sidebar">
|
||||
<div class="search-section">
|
||||
<b-form-input
|
||||
v-model="search"
|
||||
:placeholder="$t('search')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length === 0"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4 v-once>
|
||||
{{ $t('emptyMessagesLine1') }}
|
||||
</h4>
|
||||
<p v-if="!user.flags.chatRevoked">
|
||||
{{ $t('emptyMessagesLine2') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="filtersConversations.length > 0"
|
||||
class="conversations"
|
||||
>
|
||||
<div
|
||||
v-for="conversation in filtersConversations"
|
||||
:key="conversation.key"
|
||||
class="conversation"
|
||||
:class="{active: selectedConversation.key === conversation.key}"
|
||||
@click="selectConversation(conversation.key)"
|
||||
>
|
||||
<div>
|
||||
<h3 :class="userLevelStyle(conversation)">
|
||||
{{ conversation.name }}
|
||||
<div
|
||||
class="svg-icon"
|
||||
v-html="tierIcon(conversation)"
|
||||
></div>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="time">
|
||||
<span
|
||||
v-if="conversation.username"
|
||||
class="mr-1"
|
||||
>@{{ conversation.username }} •</span>
|
||||
<span v-if="conversation.date">{{ conversation.date | timeAgo }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="messagePreview"
|
||||
>
|
||||
{{ conversation.lastMessageText
|
||||
? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8 messages d-flex flex-column justify-content-between">
|
||||
<div
|
||||
v-if="!selectedConversation.key"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<div
|
||||
class="svg-icon envelope"
|
||||
v-html="icons.messageIcon"
|
||||
></div>
|
||||
<h4>{{ placeholderTexts.title }}</h4>
|
||||
<p v-html="placeholderTexts.description"></p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation && selectedConversationMessages.length === 0"
|
||||
class="empty-messages text-center"
|
||||
>
|
||||
<p>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</p>
|
||||
</div>
|
||||
<chat-messages
|
||||
v-if="selectedConversation && selectedConversationMessages.length > 0"
|
||||
ref="chatscroll"
|
||||
class="message-scroll"
|
||||
:chat="selectedConversationMessages"
|
||||
:inbox="true"
|
||||
:can-load-more="canLoadMore"
|
||||
:is-loading="messagesLoading"
|
||||
@message-removed="messageRemoved"
|
||||
@triggerLoad="infiniteScrollTrigger"
|
||||
/>
|
||||
<div
|
||||
v-if="user.inbox.optOut && selectedConversation.key"
|
||||
class="pm-disabled-caption text-center"
|
||||
>
|
||||
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
|
||||
<p>{{ $t('PMDisabledCaptionText') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedConversation.key && !user.flags.chatRevoked"
|
||||
class="new-message-row"
|
||||
>
|
||||
<textarea
|
||||
v-model="newMessage"
|
||||
maxlength="3000"
|
||||
@keyup.ctrl.enter="sendPrivateMessage()"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="sendPrivateMessage()"
|
||||
>
|
||||
{{ $t('send') }}
|
||||
</button>
|
||||
<div class="row">
|
||||
<span class="ml-3">{{ currentLength }} / 3000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#inbox-modal .modal-body {
|
||||
padding-top: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.header-wrap {
|
||||
padding: 0.5em;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0rem;
|
||||
|
||||
.svg-icon {
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.envelope {
|
||||
color: $gray-400 !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: $gray-700;
|
||||
min-height: 540px;
|
||||
padding: 0;
|
||||
|
||||
.search-section {
|
||||
padding: 1em;
|
||||
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
padding-bottom: 6em;
|
||||
height: 540px;
|
||||
}
|
||||
|
||||
.message-scroll {
|
||||
max-height: 500px;
|
||||
overflow-x: scroll;
|
||||
|
||||
@media (min-width: 992px) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.to-form input {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.empty-messages {
|
||||
margin-top: 10em;
|
||||
color: $gray-400;
|
||||
padding: 1em;
|
||||
|
||||
h4 {
|
||||
color: $gray-400;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.envelope {
|
||||
width: 30px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pm-disabled-caption {
|
||||
|
||||
padding-top: 1em;
|
||||
background-color: $gray-700;
|
||||
z-index: 2;
|
||||
|
||||
h4, p {
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.new-message-row {
|
||||
background-color: $gray-700;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 88px;
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
|
||||
textarea {
|
||||
height: 80%;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
button {
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
box-shadow: none;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations {
|
||||
max-height: 400px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
padding: 1.5em;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.conversation.active {
|
||||
border: 1px solid $purple-400;
|
||||
}
|
||||
|
||||
.conversation:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
display: block;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import moment from 'moment';
|
||||
import filter from 'lodash/filter';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import habiticaMarkdown from 'habitica-markdown';
|
||||
import axios from 'axios';
|
||||
import { mapState } from '@/libs/store';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import toggleSwitch from '@/components/ui/toggleSwitch';
|
||||
|
||||
import chatMessages from '../chat/chatMessages';
|
||||
import messageIcon from '@/assets/svg/message.svg';
|
||||
import svgClose from '@/assets/svg/close.svg';
|
||||
import tier1 from '@/assets/svg/tier-1.svg';
|
||||
import tier2 from '@/assets/svg/tier-2.svg';
|
||||
import tier3 from '@/assets/svg/tier-3.svg';
|
||||
import tier4 from '@/assets/svg/tier-4.svg';
|
||||
import tier5 from '@/assets/svg/tier-5.svg';
|
||||
import tier6 from '@/assets/svg/tier-6.svg';
|
||||
import tier7 from '@/assets/svg/tier-7.svg';
|
||||
import tier8 from '@/assets/svg/tier-mod.svg';
|
||||
import tier9 from '@/assets/svg/tier-staff.svg';
|
||||
import tierNPC from '@/assets/svg/tier-npc.svg';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
chatMessages,
|
||||
toggleSwitch,
|
||||
},
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(new Date(value)).fromNow();
|
||||
},
|
||||
},
|
||||
mixins: [styleHelper],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
messageIcon,
|
||||
svgClose,
|
||||
tier1,
|
||||
tier2,
|
||||
tier3,
|
||||
tier4,
|
||||
tier5,
|
||||
tier6,
|
||||
tier7,
|
||||
tier8,
|
||||
tier9,
|
||||
tierNPC,
|
||||
}),
|
||||
displayCreate: true,
|
||||
selectedConversation: {},
|
||||
search: '',
|
||||
newMessage: '',
|
||||
showPopover: false,
|
||||
messages: [],
|
||||
messagesByConversation: {}, // cache {uuid: []}
|
||||
loadedConversations: [],
|
||||
loaded: false,
|
||||
messagesLoading: false,
|
||||
initiatedConversation: null,
|
||||
updateConversionsCounter: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
canLoadMore () {
|
||||
return this.selectedConversation && this.selectedConversation.canLoadMore;
|
||||
},
|
||||
conversations () {
|
||||
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
|
||||
|
||||
// Add placeholder for new conversations
|
||||
if (this.initiatedConversation && this.initiatedConversation.uuid) {
|
||||
inboxGroup[this.initiatedConversation.uuid] = [{
|
||||
uuid: this.initiatedConversation.uuid,
|
||||
user: this.initiatedConversation.user,
|
||||
username: this.initiatedConversation.username,
|
||||
contributor: this.initiatedConversation.contributor,
|
||||
id: '',
|
||||
text: '',
|
||||
timestamp: new Date(),
|
||||
}];
|
||||
}
|
||||
// Create conversation objects
|
||||
const convos = [];
|
||||
for (const key of Object.keys(inboxGroup)) {
|
||||
const recentMessage = inboxGroup[key][0];
|
||||
|
||||
const convoModel = {
|
||||
key: recentMessage.uuid,
|
||||
// Handles case where from user sent
|
||||
// the only message or the to user sent the only message
|
||||
name: recentMessage.user,
|
||||
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
|
||||
date: recentMessage.timestamp,
|
||||
lastMessageText: recentMessage.text,
|
||||
canLoadMore: true,
|
||||
page: 0,
|
||||
};
|
||||
|
||||
convos.push(convoModel);
|
||||
}
|
||||
|
||||
return convos;
|
||||
},
|
||||
// Separate from selectedConversation which
|
||||
// is not computed so messages don't update automatically
|
||||
selectedConversationMessages () {
|
||||
// Vue-subscribe to changes
|
||||
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
|
||||
|
||||
const selectedConversationKey = this.selectedConversation.key;
|
||||
const selectedConversation = this.messagesByConversation[selectedConversationKey];
|
||||
this.messages = selectedConversation || []; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
|
||||
|
||||
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
|
||||
|
||||
if (subScribeToUpdate) {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
filtersConversations () {
|
||||
// Vue-subscribe to changes
|
||||
const subScribeToUpdate = this.updateConversionsCounter > -1;
|
||||
|
||||
const filtered = subScribeToUpdate && !this.search
|
||||
? this.conversations
|
||||
: filter(
|
||||
this.conversations,
|
||||
conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1,
|
||||
);
|
||||
|
||||
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
|
||||
|
||||
return ordered;
|
||||
},
|
||||
currentLength () {
|
||||
return this.newMessage.length;
|
||||
},
|
||||
placeholderTexts () {
|
||||
if (this.user.flags.chatRevoked) {
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitleRevoked'),
|
||||
description: this.$t('chatPrivilegesRevoked'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: this.$t('PMPlaceholderTitle'),
|
||||
description: this.$t('PMPlaceholderDescription'),
|
||||
};
|
||||
},
|
||||
optTextSet () {
|
||||
if (!this.user.inbox.optOut) {
|
||||
return {
|
||||
switchDescription: this.$t('PMReceive'),
|
||||
popoverText: this.$t('PMEnabledOptPopoverText'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
switchDescription: this.$t('PMReceive'),
|
||||
popoverText: this.$t('PMDisabledOptPopoverText'),
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$root.$on('habitica::new-inbox-message', data => {
|
||||
this.$root.$emit('bv::show::modal', 'inbox-modal');
|
||||
|
||||
// Wait for messages to be loaded
|
||||
const unwatchLoaded = this.$watch('loaded', loaded => {
|
||||
if (!loaded) return;
|
||||
|
||||
const conversation = this.conversations.find(convo => convo.key === data.userIdToMessage);
|
||||
if (loaded) setImmediate(() => unwatchLoaded());
|
||||
|
||||
if (conversation) {
|
||||
this.selectConversation(data.userIdToMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.initiatedConversation = {
|
||||
uuid: data.userIdToMessage,
|
||||
user: data.displayName,
|
||||
username: data.username,
|
||||
backer: data.backer,
|
||||
contributor: data.contributor,
|
||||
};
|
||||
|
||||
this.selectConversation(data.userIdToMessage);
|
||||
}, { immediate: true });
|
||||
});
|
||||
},
|
||||
destroyed () {
|
||||
this.$root.$off('habitica::new-inbox-message');
|
||||
},
|
||||
methods: {
|
||||
async onModalShown () {
|
||||
this.loaded = false;
|
||||
|
||||
const conversationRes = await axios.get('/api/v4/inbox/conversations');
|
||||
this.loadedConversations = conversationRes.data.data;
|
||||
|
||||
this.loaded = true;
|
||||
},
|
||||
onModalHide () {
|
||||
// reset everything
|
||||
this.loadedConversations = [];
|
||||
this.loaded = false;
|
||||
this.initiatedConversation = null;
|
||||
this.messagesByConversation = {};
|
||||
this.selectedConversation = {};
|
||||
},
|
||||
messageRemoved (message) {
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
const messageIndex = messages.findIndex(msg => msg.id === message.id);
|
||||
if (messageIndex !== -1) messages.splice(messageIndex, 1);
|
||||
if (this.selectedConversationMessages.length === 0) {
|
||||
this.initiatedConversation = {
|
||||
uuid: this.selectedConversation.key,
|
||||
user: this.selectedConversation.name,
|
||||
username: this.selectedConversation.username,
|
||||
backer: this.selectedConversation.backer,
|
||||
contributor: this.selectedConversation.contributor,
|
||||
};
|
||||
}
|
||||
},
|
||||
toggleClick () {
|
||||
this.displayCreate = !this.displayCreate;
|
||||
},
|
||||
toggleOpt () {
|
||||
this.$store.dispatch('user:togglePrivateMessagesOpt');
|
||||
},
|
||||
async selectConversation (key) {
|
||||
const convoFound = this.conversations.find(conversation => conversation.key === key);
|
||||
|
||||
this.selectedConversation = convoFound || {};
|
||||
|
||||
if (!this.messagesByConversation[this.selectedConversation.key]) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
},
|
||||
sendPrivateMessage () {
|
||||
if (!this.newMessage) return;
|
||||
|
||||
const messages = this.messagesByConversation[this.selectedConversation.key];
|
||||
|
||||
messages.push({
|
||||
sent: true,
|
||||
text: this.newMessage,
|
||||
timestamp: new Date(),
|
||||
toUser: this.selectedConversation.name,
|
||||
toUserName: this.selectedConversation.username,
|
||||
toUserContributor: this.selectedConversation.contributor,
|
||||
toUserBacker: this.selectedConversation.backer,
|
||||
toUUID: this.selectedConversation.uuid,
|
||||
|
||||
id: '-1', // will be updated once the result is back
|
||||
likes: {},
|
||||
ownerId: this.user._id,
|
||||
uuid: this.user._id,
|
||||
fromUUID: this.user._id,
|
||||
user: this.user.profile.name,
|
||||
username: this.user.auth.local.username,
|
||||
contributor: this.user.contributor,
|
||||
backer: this.user.backer,
|
||||
});
|
||||
|
||||
// Remove the placeholder message
|
||||
if (
|
||||
this.initiatedConversation
|
||||
&& this.initiatedConversation.uuid === this.selectedConversation.key
|
||||
) {
|
||||
this.loadedConversations.unshift(this.initiatedConversation);
|
||||
this.initiatedConversation = null;
|
||||
}
|
||||
|
||||
this.selectedConversation.lastMessageText = this.newMessage;
|
||||
this.selectedConversation.date = new Date();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
if (!this.$refs.chatscroll) return;
|
||||
const chatscroll = this.$refs.chatscroll.$el;
|
||||
chatscroll.scrollTop = chatscroll.scrollHeight;
|
||||
});
|
||||
|
||||
this.$store.dispatch('members:sendPrivateMessage', {
|
||||
toUserId: this.selectedConversation.key,
|
||||
message: this.newMessage,
|
||||
}).then(response => {
|
||||
const newMessage = response.data.data.message;
|
||||
const messageToReset = messages[messages.length - 1];
|
||||
messageToReset.id = newMessage.id; // just set the id, all other infos already set
|
||||
Object.assign(messages[messages.length - 1], messageToReset);
|
||||
this.updateConversionsCounter += 1;
|
||||
});
|
||||
|
||||
this.newMessage = '';
|
||||
},
|
||||
close () {
|
||||
this.$root.$emit('bv::hide::modal', 'inbox-modal');
|
||||
},
|
||||
tierIcon (message) {
|
||||
const isNPC = Boolean(message.backer && message.backer.npc);
|
||||
if (isNPC) {
|
||||
return this.icons.tierNPC;
|
||||
}
|
||||
if (!message.contributor) return null;
|
||||
return this.icons[`tier${message.contributor.level}`];
|
||||
},
|
||||
removeTags (html) {
|
||||
const tmp = document.createElement('DIV');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
},
|
||||
parseMarkdown (text) {
|
||||
if (!text) return null;
|
||||
return habiticaMarkdown.render(String(text));
|
||||
},
|
||||
infiniteScrollTrigger () {
|
||||
// show loading and wait until the loadMore debounced
|
||||
// or else it would trigger on every scrolling-pixel (while not loading)
|
||||
if (this.canLoadMore) {
|
||||
this.messagesLoading = true;
|
||||
}
|
||||
|
||||
return this.loadMore();
|
||||
},
|
||||
loadMore () {
|
||||
this.selectedConversation.page += 1;
|
||||
return this.loadMessages();
|
||||
},
|
||||
async loadMessages () {
|
||||
this.messagesLoading = true;
|
||||
|
||||
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${this.selectedConversation.key}&page=${this.selectedConversation.page}`;
|
||||
const res = await axios.get(requestUrl);
|
||||
const loadedMessages = res.data.data;
|
||||
|
||||
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || []; // eslint-disable-line max-len
|
||||
const loadedMessagesToAdd = loadedMessages
|
||||
.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1); // eslint-disable-line max-len
|
||||
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
|
||||
|
||||
// only show the load more Button if the max count was returned
|
||||
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
|
||||
this.messagesLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -859,13 +859,12 @@ export default {
|
||||
window.history.replaceState(null, null, '');
|
||||
},
|
||||
sendMessage () {
|
||||
this.$root.$emit('habitica::new-inbox-message', {
|
||||
userIdToMessage: this.user._id,
|
||||
displayName: this.user.profile.name,
|
||||
username: this.user.auth.local.username,
|
||||
backer: this.user.backer,
|
||||
contributor: this.user.contributor,
|
||||
this.$store.dispatch('user:newPrivateMessageTo', {
|
||||
member: this.user,
|
||||
});
|
||||
|
||||
this.$router.push('/private-messages');
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
},
|
||||
getProgressDisplay () {
|
||||
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];
|
||||
|
||||
867
website/client/src/pages/private-messages.vue
Normal file
867
website/client/src/pages/private-messages.vue
Normal 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>
|
||||
@@ -75,6 +75,8 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com
|
||||
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
|
||||
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
|
||||
|
||||
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages');
|
||||
|
||||
// Challenges
|
||||
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
|
||||
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',
|
||||
path: '/challenges',
|
||||
|
||||
@@ -160,3 +160,65 @@ export async function userLookup (store, params) {
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +127,13 @@ export default function () {
|
||||
equipmentDrawerOpen: true,
|
||||
groupPlans: [],
|
||||
isRunningYesterdailies: false,
|
||||
privateMessageOptions: {
|
||||
userIdToMessage: '',
|
||||
displayName: '',
|
||||
username: '',
|
||||
backer: {},
|
||||
contributor: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
9
website/client/webpack.webstorm.config
Normal file
9
website/client/webpack.webstorm.config
Normal file
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, 'src'),
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -138,6 +138,7 @@
|
||||
"PMPlaceholderDescription": "Select a conversation on the left",
|
||||
"PMPlaceholderTitleRevoked": "Your chat privileges have been revoked",
|
||||
"PMReceive": "Receive Private Messages",
|
||||
"PMDisabled": "Disable Private Messages",
|
||||
"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.",
|
||||
"PMDisabledCaptionTitle": "Private Messages are disabled",
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"notificationsRequired": "Notification ids are required.",
|
||||
"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.",
|
||||
"messageMissingDisplayName": "Missing display name.",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import apiError from '../../libs/apiError';
|
||||
import * as inboxLib from '../../libs/inbox';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/errors';
|
||||
import { listConversations } from '../../libs/inbox/conversation.methods';
|
||||
import { clearPMs, deleteMessage, getUserInbox } from '../../libs/inbox';
|
||||
|
||||
const api = {};
|
||||
|
||||
@@ -40,7 +41,7 @@ api.deleteMessage = {
|
||||
const { messageId } = req.params;
|
||||
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'));
|
||||
|
||||
res.respond(200);
|
||||
@@ -66,7 +67,7 @@ api.clearMessages = {
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
|
||||
await inboxLib.clearPMs(user);
|
||||
await clearPMs(user);
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
@@ -101,7 +102,7 @@ api.conversations = {
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
|
||||
const result = await inboxLib.listConversations(user);
|
||||
const result = await listConversations(user);
|
||||
|
||||
res.respond(200, result);
|
||||
},
|
||||
@@ -128,7 +129,7 @@ api.getInboxMessages = {
|
||||
const { page } = req.query;
|
||||
const { conversation } = req.query;
|
||||
|
||||
const userInbox = await inboxLib.getUserInbox(user, {
|
||||
const userInbox = await getUserInbox(user, {
|
||||
page, conversation, mapProps: true,
|
||||
});
|
||||
|
||||
|
||||
121
website/server/libs/inbox/conversation.methods.js
Normal file
121
website/server/libs/inbox/conversation.methods.js
Normal 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;
|
||||
}
|
||||
@@ -80,74 +80,6 @@ export async function getUserInbox (user, options = {
|
||||
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) {
|
||||
return Inbox.findOne({ ownerId: user._id, _id: messageId }).exec();
|
||||
}
|
||||
|
||||
@@ -107,8 +107,11 @@ export function setUserStyles (newMessage, user) {
|
||||
|
||||
newMessage.contributor = contributorCopy;
|
||||
newMessage.userStyles = userStyles;
|
||||
|
||||
if (newMessage.markModified) {
|
||||
newMessage.markModified('userStyles contributor');
|
||||
}
|
||||
}
|
||||
|
||||
export function messageDefaults (msg, user, client, flagCount = 0, info = {}) {
|
||||
const id = uuid();
|
||||
|
||||
Reference in New Issue
Block a user