Merge branch 'release' into sabrecat/timetrav-bgs

This commit is contained in:
Sabe Jones
2020-01-21 14:58:51 -06:00
311 changed files with 13085 additions and 5455 deletions
+33 -7
View File
@@ -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 () {
@@ -1,21 +1,27 @@
.promo_armoire_backgrounds_202001 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -175px;
background-position: 0px -323px;
width: 423px;
height: 147px;
}
.promo_g1g1_2019 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -471px;
background-position: -403px -471px;
width: 357px;
height: 144px;
}
.promo_mystery_202001 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -241px -616px;
background-position: -241px -619px;
width: 279px;
height: 147px;
}
.promo_seasonal_shop {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -521px -619px;
width: 162px;
height: 138px;
}
.promo_snowballs {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px 0px;
@@ -24,25 +30,25 @@
}
.promo_take_this {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -358px -471px;
background-position: -761px -471px;
width: 96px;
height: 69px;
}
.promo_winter_potions_2020 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -175px;
background-position: 0px -175px;
width: 423px;
height: 147px;
}
.promo_winter_quests_bundle {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -323px;
background-position: -424px -175px;
width: 423px;
height: 147px;
}
.promo_winter_wonderland_2019 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -323px;
background-position: 0px -471px;
width: 402px;
height: 147px;
}
@@ -52,9 +58,27 @@
width: 468px;
height: 147px;
}
.promo_wintery_hair {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -684px -619px;
width: 152px;
height: 75px;
}
.promo_wintery_skins {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -323px;
width: 420px;
height: 147px;
}
.customize-option.promo_wintery_skins {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -449px -338px;
width: 60px;
height: 60px;
}
.scene_list {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -616px;
background-position: 0px -619px;
width: 240px;
height: 195px;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 95 KiB

@@ -7,3 +7,6 @@ $npc_quests_flavor: 'nye';
$npc_seasonal_flavor: 'nye';
$npc_timetravelers_flavor: 'winter';
$npc_tavern_flavor: 'nye';
$restingToolbarHeight: 40px;
$menuToolbarHeight: 56px;
@@ -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
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="24" viewBox="0 0 32 24">
<g fill="none" fill-rule="evenodd">
<path fill="#9A62FF" d="M28 0H4C1.79 0 0 1.79 0 4v16c0 2.21 1.79 4 4 4h24c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z"/>
<path fill="#BDA8FF" d="M28 20H4V4l12 10L28 4z"/>
<path fill="#D5C8FF" d="M28 20H4V7l12 10L28 7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 380 B

@@ -32,7 +32,7 @@
:avatar-only="true"
:with-background="true"
/>
<!-- @TOOD: Sleep +generatedAvatar({sleep:true})-->
<!-- @TODO: Sleep +generatedAvatar({sleep:true})-->
<span class="knockout"></span>
</div>
</div>
@@ -71,8 +71,8 @@ export default {
this.$root.$emit('bv::hide::modal', 'streak');
},
suppressModals () {
const surpress = !!this.user.preferences.suppressModals.streak;
this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': surpress });
const suppress = !!this.user.preferences.suppressModals.streak;
this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': suppress });
},
},
};
@@ -1,5 +1,18 @@
<template>
<div class="form-wrapper">
<div class="warning-banner d-flex" v-if="forgotPassword && preOutage">
<div class="warning-box ml-auto my-auto mr-2 d-flex">
<div
class="svg-icon exclamation m-auto"
v-html="icons.exclamation"
>
</div>
</div>
<div class="mr-auto my-auto">
Habitica emails will be temporarily unavailable on <strong>January 11, 2020</strong> from
<strong>1:00 - 7:00 AM EST</strong>.
</div>
</div>
<div id="top-background">
<div class="seamless_stars_varied_opacity_repeat"></div>
</div>
@@ -546,15 +559,36 @@
font-size: 90%;
width: 100%;
}
.warning-banner {
color: $white;
background-color: $maroon-100;
height: 2.5rem;
width: 100%;
}
.warning-box {
font-weight: bold;
width: 1rem;
height: 1rem;
border: 2px solid;
border-radius: 2px;
}
.exclamation {
width: 2px;
}
</style>
<script>
import axios from 'axios';
import hello from 'hellojs';
import moment from 'moment';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import exclamation from '@/assets/svg/exclamation.svg';
import gryphon from '@/assets/svg/gryphon.svg';
import habiticaIcon from '@/assets/svg/habitica-logo.svg';
import facebookSquareIcon from '@/assets/svg/facebook-square.svg';
@@ -576,6 +610,7 @@ export default {
};
data.icons = Object.freeze({
exclamation,
gryphon,
habiticaIcon,
facebookIcon: facebookSquareIcon,
@@ -635,6 +670,9 @@ export default {
|| this.passwordInvalid
|| this.passwordConfirmInvalid;
},
preOutage () {
return moment.utc().isBefore('2020-01-12');
},
},
watch: {
$route: {
@@ -700,7 +738,7 @@ export default {
return;
}
// @TODO: implement langauge and invite accepting
// @TODO: implement language and invite accepting
// var url = ApiUrl.get() + "/api/v4/user/auth/local/register";
// if (location.search && location.search.indexOf('Invite=') !== -1)
// { // matches groupInvite and partyInvite
@@ -294,8 +294,9 @@
justify-content: space-evenly;
background-color: $gray-700;
text-align: center;
padding: 16px;
padding: 8px;
border-radius: .25em;
flex-wrap: wrap;
> div {
.value {
@@ -326,6 +327,8 @@
}
> div.muted {
margin: 8px;
.value {
opacity: 0.5;
font-size: 20px;
@@ -101,7 +101,7 @@
</div>
</div>
<div
v-if="showCategorySelect && creating"
v-if="showCategorySelect"
class="category-box"
>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
@@ -68,6 +68,17 @@
<style lang='scss' scoped>
@import '~@/assets/scss/colors.scss';
@media only screen and (max-width: 768px) {
.header-row {
margin-bottom: 1rem;
}
.col-10.standard-page {
// full width on smaller devices
max-width: 100%;
}
}
.header-row {
h1 {
color: $purple-200;
@@ -88,6 +88,17 @@
<style lang='scss' scoped>
@import '~@/assets/scss/colors.scss';
@media only screen and (max-width: 768px) {
.header-row {
margin-bottom: 1rem;
}
.col-10.standard-page {
// full width on smaller devices
max-width: 100%;
}
}
.header-row {
h1 {
color: $purple-200;
@@ -5,7 +5,7 @@
class="mentioned-icon"
></div>
<div
v-if="!inbox && user.contributor.admin && msg.flagCount"
v-if="user.contributor.admin && msg.flagCount"
class="message-hidden"
>
{{ flagCountDescription }}
@@ -27,8 +27,7 @@
class="mr-1"
></span>
<span
v-b-tooltip
:title="msg.timestamp | date"
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p>
@@ -37,21 +36,12 @@
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
<div
v-if="isMessageReported && (inbox === true)"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span>
<br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
v-if="!inbox"
class="action d-flex align-items-center"
@click="copyAsTodo(msg)"
>
@@ -62,7 +52,7 @@
<div>{{ $t('copyAsTodo') }}</div>
</div>
<div
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system'))
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
&& (!isMessageReported || user.contributor.admin)"
class="action d-flex align-items-center"
@click="report(msg)"
@@ -77,7 +67,7 @@
</div>
</div>
<div
v-if="msg.uuid === user._id || inbox || user.contributor.admin"
v-if="msg.uuid === user._id || user.contributor.admin"
class="action d-flex align-items-center"
@click="remove()"
>
@@ -91,7 +81,6 @@
</div>
</div>
<div
v-if="!inbox"
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
class="ml-auto d-flex"
>
@@ -121,7 +110,7 @@
></div>
</div>
</div>
<span v-if="!msg.likes[user._id] && !inbox">{{ $t('like') }}</span>
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
</div>
</div>
</div>
@@ -205,15 +194,9 @@
color: $purple-400;
}
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
@@ -244,10 +227,6 @@ export default {
},
props: {
msg: {},
inbox: {
type: Boolean,
default: false,
},
groupId: {},
},
data () {
@@ -311,6 +290,10 @@ export default {
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)';
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
@@ -372,11 +355,6 @@ export default {
const message = this.msg;
this.$emit('message-removed', message);
if (this.inbox) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
return;
}
await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId,
chatId: message.id,
@@ -35,13 +35,11 @@
v-for="msg in messages"
v-if="chat && canViewFlag(msg)"
:key="msg.id"
:class="{row: inbox}"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div
v-if="user._id !== msg.uuid"
class="d-flex"
:class="{'flex-grow-1': inbox}"
>
<avatar
v-if="msg.userStyles
@@ -51,16 +49,13 @@
:avatar-only="true"
:override-top-padding="'14px'"
:hide-class-badge="true"
:class="{'inbox-avatar-left': inbox}"
@click.native="showMemberModal(msg.uuid)"
/>
<div
class="card"
:class="{'col-10': inbox}"
>
<chat-card
:msg="msg"
:inbox="inbox"
:group-id="groupId"
@message-liked="messageLiked"
@message-removed="messageRemoved"
@@ -72,15 +67,12 @@
<div
v-if="user._id === msg.uuid"
class="d-flex"
:class="{'flex-grow-1': inbox}"
>
<div
class="card"
:class="{'col-10': inbox}"
>
<chat-card
:msg="msg"
:inbox="inbox"
:group-id="groupId"
@message-liked="messageLiked"
@message-removed="messageRemoved"
@@ -95,7 +87,6 @@
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
:class="{'inbox-avatar-right': inbox}"
@click.native="showMemberModal(msg.uuid)"
/>
</div>
@@ -144,16 +135,6 @@
margin-right: 2rem;
}
.inbox-avatar-left {
margin-left: -1rem;
margin-right: 2.5rem;
min-width: 5rem;
}
.inbox-avatar-right {
margin-left: -3.5rem;
}
.hr {
width: 100%;
height: 20px;
@@ -209,10 +190,6 @@ export default {
},
props: {
chat: {},
inbox: {
type: Boolean,
default: false,
},
groupType: {},
groupId: {},
groupName: {},
@@ -260,12 +237,6 @@ export default {
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll
container.style.overflowY = 'hidden';
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
if (canLoadMore) {
await this.$emit('triggerLoad');
this.handleScrollBack = true;
}
},
canViewFlag (message) {
if (message.uuid === this.user._id) return true;
@@ -282,7 +253,7 @@ export default {
const promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination
// @TODO: write an explanation
// @TODO: Remove this after enough messages are cached
if (
!noProfilesLoaded
@@ -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);
},
@@ -23,7 +23,7 @@
</div>
<div class="footer text-center">
<button
v-if="user.contributor.admin && abuseObject.flagCount > 0"
v-if="user.contributor.admin"
class="pull-left btn btn-danger"
@click="clearFlagCount()"
>
@@ -382,7 +382,7 @@
<div class="task-option">
<div class="custom-control custom-checkbox">
<input
id="excercise"
id="exercise"
v-model="taskCategories"
class="custom-control-input"
type="checkbox"
@@ -391,7 +391,7 @@
<label
v-once
class="custom-control-label"
for="excercise"
for="exercise"
>{{ $t('exercise') }}</label>
</div>
</div>
@@ -0,0 +1,47 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import FaceAvatar from './faceAvatar.vue';
import Avatar from './avatar.vue';
import { userStyles } from '../../config/storybook/mock.data';
import content from '../../../common/script/content/index';
import getters from '@/store/getters';
storiesOf('Face Avatar', module)
.add('simple', () => ({
components: { FaceAvatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
}))
.add('compare', () => ({
components: { FaceAvatar, Avatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
<avatar :member="user"></avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
state: {
content,
},
store: {
getters,
state: {
content,
},
},
}));
@@ -0,0 +1,154 @@
<template>
<div
class="face-avatar"
:style="{width, height}"
>
<div class="character-sprites">
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
<template v-for="(klass, item) in visualBuffs">
<span
v-if="member.stats.buffs[item] && showVisualBuffs"
:key="klass"
:class="klass"
></span>
</template>
<!-- Show flower ALL THE TIME!!!-->
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
<!-- Show avatar only if not currently affected by visual buff-->
<template v-if="showAvatar()">
<span :class="[skinClass]"></span><span :class="['head_0']"></span>
<template v-for="type in ['bangs', 'base', 'mustache', 'beard']">
<span
:key="type"
:class="[getHairClass(type)]"
></span>
</template>
<span :class="[getGearClass('body')]"></span>
<span :class="[getGearClass('eyewear')]"></span>
<span :class="[getGearClass('head')]"></span>
<span :class="[getGearClass('headAccessory')]"></span>
<span :class="['hair_flower_' + member.preferences.hair.flower]"></span>
</template>
<!-- Resting--><span
v-if="member.preferences.sleep"
class="zzz"
></span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.face-avatar {
width: 36px;
height: 36px;
border: solid 2px currentColor;
border-radius: 18px;
image-rendering: pixelated;
position: relative;
overflow: hidden;
}
.character-sprites {
width: 90px;
height: 90px;
margin: -25px -41px;
}
.character-sprites span {
position: absolute;
}
</style>
<script>
import { mapState } from '@/libs/store';
export default {
components: {
},
props: {
member: {
type: Object,
required: true,
},
avatarOnly: {
type: Boolean,
default: false,
},
hideClassBadge: {
type: Boolean,
default: false,
},
withBackground: {
type: Boolean,
},
overrideAvatarGear: {
type: Object,
},
width: {
type: Number,
default: 140,
},
height: {
type: Number,
default: 147,
},
showVisualBuffs: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
flatGear: 'content.gear.flat',
}),
hasClass () {
return this.$store.getters['members:hasClass'](this.member);
},
isBuffed () {
return this.$store.getters['members:isBuffed'](this.member);
},
visualBuffs () {
return {
snowball: 'snowman',
spookySparkles: 'ghost',
shinySeed: `avatar_floral_${this.member.stats.class}`,
seafoam: 'seafoam_star',
};
},
skinClass () {
const baseClass = `skin_${this.member.preferences.skin}`;
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
},
costumeClass () {
return this.member.preferences.costume ? 'costume' : 'equipped';
},
},
methods: {
getGearClass (gearType) {
let result = this.member.items.gear[this.costumeClass][gearType];
if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
result = this.overrideAvatarGear[gearType];
}
return result;
},
showAvatar () {
if (!this.showVisualBuffs) return true;
const { buffs } = this.member.stats;
return !buffs.snowball && !buffs.spookySparkles && !buffs.shinySeed && !buffs.seafoam;
},
getHairClass (type) {
const hairPref = this.member.preferences.hair;
return `hair_${type}_${hairPref[type]}_${hairPref.color}`;
},
},
};
</script>
@@ -499,7 +499,7 @@ export default {
},
watch: {
groupId () {
// @TOOD: We might not need this since groupId is computed now
// @TODO: We might not need this since groupId is computed now
this.getMembers();
},
challengeId () {
@@ -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,
@@ -3,7 +3,7 @@
:can-remove="canRemove"
:has-icon="true"
:notification="notification"
:read-after-click="true"
:read-after-click="false"
@click="action"
>
<div
@@ -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
@@ -43,7 +43,7 @@
</a>
<a
class="dropdown-item"
@click="showAvatar('backgrounds', '2019')"
@click="showAvatar('backgrounds', '2020')"
>{{ $t('backgrounds') }}</a>
<a
class="dropdown-item"
@@ -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 });
@@ -341,7 +341,7 @@ export default {
const { firstRender } = viewOptions;
const { itemsInFirstPosition } = viewOptions;
// Render selected items in first postion only for the first render
// Render selected items in first position only for the first render
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
gearItemsByType[type].unshift(ownedItem);
} else if (isEquipped === true && firstRender === true) {
@@ -382,7 +382,7 @@ export default {
const { firstRender } = viewOptions;
const { itemsInFirstPosition } = viewOptions;
// Render selected items in first postion only for the first render
// Render selected items in first position only for the first render
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
gearItemsByClass[klass].unshift(ownedItem);
} else if (isEquipped === true && firstRender === true) {
@@ -0,0 +1,235 @@
<template>
<div
class="conversation"
:class="{active: activeKey === uuid}"
@click="$emit('click', {})"
@mouseleave="hideDropDown()"
>
<div class="user">
<user-label
:backer="backer"
:contributor="contributor"
:name="displayName"
/><span
v-if="username"
class="username"
>@{{ username }}</span>
<div
v-if="lastMessageDate"
class="time"
>
{{ lastMessageDate | timeAgo }}
</div>
</div>
<div class="preview-row">
<div class="messagePreview">
{{ lastMessageText }}
</div>
<div
v-if="userLoggedIn.id !== uuid"
class="actions"
>
<b-dropdown
ref="dropdown"
class="action-dropdown"
right
toggle-class="btn-flat action-padding"
no-caret
variant="link"
size="lg"
>
<template slot="button-content">
<div
class="svg-icon inline dots"
v-html="icons.dots"
></div>
</template>
<b-dropdown-item @click="block()">
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.remove"
></div><span class="text">{{ $t('block') }}</span></span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</template>
<script>
import moment from 'moment';
import userLabel from '../userLabel';
import dots from '@/assets/svg/dots.svg';
import remove from '@/assets/svg/remove.svg';
import { mapState } from '@/libs/store';
export default {
components: {
userLabel,
},
props: [
'activeKey', 'uuid', 'backer', 'displayName',
'username', 'contributor', 'lastMessageText',
'lastMessageDate',
],
computed: {
...mapState({
userLoggedIn: 'user.data',
}),
},
data () {
return {
icons: Object.freeze({
dots,
remove,
}),
};
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
methods: {
hideDropDown () {
const { dropdown } = this.$refs;
if (dropdown) {
dropdown.hide();
}
},
block () {
this.$store.dispatch('user:block', {
uuid: this.uuid,
});
},
},
};
</script>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
.action-padding {
height: 24px !important;
width: 24px;
padding: 0 !important;
}
.action-dropdown {
.dropdown-item {
padding: 12px 16px;
.svg-icon {
width: 16px;
height: 16px;
}
}
.dots {
height: 16px;
width: 4px;
svg path {
fill: $purple-300
}
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.conversation {
padding: 1.5rem;
border-bottom: 1px solid $gray-500;
&:hover {
cursor: pointer;
background: #EEE;
.actions {
display: block;
}
}
&.active {
background-color: #f1edff;
}
.user {
display: flex;
flex-direction: row;
height: 20px;
.user-label {
flex: 1;
flex-grow: 0;
margin-right: 0.5rem;
white-space: nowrap;
}
.username {
flex: 1;
flex-grow: 0;
}
.time {
flex: 2;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 1rem;
}
}
.messagePreview {
//width: 100%;
height: 30px;
margin-right: 40px;
margin-top: 4px;
font-size: 12px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
color: $gray-100;
overflow: hidden;
// text-overflow: ellipsis;
}
}
.preview-row {
display: flex;
flex-direction: row;
position: relative;
.messagePreview {
flex: 1;
width: calc(100% - 16px);
}
}
.actions {
position: absolute;
right: 0;
display: none;
width: 16px;
margin-top: 4px;
.dots {
height: 16px;
width: 4px;
}
.action-icon {
margin-right: 1em;
}
}
</style>
@@ -0,0 +1,216 @@
<template>
<div class="card-body">
<user-link
:user-id="msg.uuid"
:name="msg.user"
:backer="msg.backer"
:contributor="msg.contributor"
/>
<p class="time">
<span
v-if="msg.username"
class="mr-1"
>@{{ msg.username }}</span><span
v-if="msg.username"
class="mr-1"
></span>
<span
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span>
</p>
<div
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
<div
v-if="isMessageReported"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span><br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
v-if="!isMessageReported"
class="action d-flex align-items-center"
@click="report(msg)"
>
<div
v-once
class="svg-icon"
v-html="icons.report"
></div>
<div v-once>
{{ $t('report') }}
</div>
</div>
<div
class="action d-flex align-items-center"
@click="remove()"
>
<div
v-once
class="svg-icon"
v-html="icons.delete"
></div>
<div v-once>
{{ $t('delete') }}
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem;
}
.at-text {
color: #6133b4;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.action {
display: inline-block;
color: $gray-200;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: $gray-300;
margin-right: .2em;
width: 16px;
}
}
.active {
color: $purple-300;
.svg-icon {
color: $purple-400;
}
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: $gray-50;
text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
}
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
import userLink from '../userLink';
import deleteIcon from '@/assets/svg/delete.svg';
import reportIcon from '@/assets/svg/report.svg';
import { highlightUsers } from '../../libs/highlightUsers';
export default {
components: {
userLink,
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
props: {
msg: {},
},
data () {
return {
icons: Object.freeze({
delete: deleteIcon,
report: reportIcon,
}),
reported: false,
};
},
computed: {
...mapState({ user: 'user.data' }),
isMessageReported () {
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
this.$emit('message-card-mounted');
},
methods: {
report () {
this.$root.$on('habitica:report-result', data => {
if (data.ok) {
this.reported = true;
}
this.$root.$off('habitica:report-result');
});
this.$root.$emit('habitica::report-chat', {
message: this.msg,
groupId: 'privateMessage',
});
},
async remove () {
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return;
const message = this.msg;
this.$emit('message-removed', message);
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
},
atHighlight (text) {
return highlightUsers(text, this.user.auth.local.username, this.user.profile.name);
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
},
};
</script>
@@ -0,0 +1,400 @@
<template>
<perfect-scrollbar
ref="container"
class="container-fluid"
:class="{'disable-perfect-scroll': disablePerfectScroll}"
:options="psOptions"
>
<div class="row loadmore">
<div v-if="canLoadMore && !isLoading">
<div class="loadmore-divider"></div>
<button
class="btn btn-secondary"
@click="triggerLoad()"
>
{{ $t('loadEarlierMessages') }}
</button>
<div class="loadmore-divider"></div>
</div>
<h2
v-show="isLoading"
class="col-12 loading"
>
{{ $t('loading') }}
</h2>
</div>
<div
v-for="(msg) in messages"
:key="msg.id"
class="row message-row"
:class="{ 'margin-right': user._id !== msg.uuid}"
>
<div
v-if="user._id !== msg.uuid"
class="d-flex flex-grow-1"
>
<avatar
v-if="msg.userStyles || (cachedProfileData[msg.uuid]
&& !cachedProfileData[msg.uuid].rejected)"
class="avatar-left"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:override-top-padding="'14px'"
:hide-class-badge="true"
@click.native="showMemberModal(msg.uuid)"
/>
<div class="card card-right">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
</div>
<div
v-if="user._id === msg.uuid"
class="d-flex flex-grow-1"
>
<div class="card card-left">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
<avatar
v-if="msg.userStyles
|| (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)"
class="avatar-right"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
@click.native="showMemberModal(msg.uuid)"
/>
</div>
</div>
</perfect-scrollbar>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css';
.disable-perfect-scroll {
overflow-y: inherit !important;
}
.avatar {
width: 15%;
min-width: 8rem;
height: 120px;
padding-top: 0 !important;
}
.avatar-left {
margin-left: -1rem;
}
.avatar-right {
margin-left: -1rem;
}
.card {
border: 0px;
margin-bottom: 1rem;
padding: 0rem;
width: 684px;
}
.message-row {
margin-left: 12px;
margin-right: 12px;
&:not(.margin-right) {
.d-flex {
justify-content: flex-end;
}
}
}
@media only screen and (max-width: 1200px) {
.card {
width: 100%;
}
}
@media only screen and (min-width: 1400px) {
.message-row {
margin-left: -15px;
margin-right: -30px;
}
}
.card-left {
border: 1px solid $purple-500;
}
.card-right {
border: 1px solid $gray-500;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.hr-middle {
font-size: 16px;
font-weight: bold;
font-family: 'Roboto Condensed';
line-height: 1.5;
text-align: center;
color: $gray-200;
background-color: $gray-700;
padding: .2em;
margin-top: .2em;
display: inline-block;
width: 100px;
}
.loadmore {
justify-content: center;
margin-right: 12px;
> div {
display: flex;
width: 100%;
align-items: center;
button {
text-align: center;
color: $gray-50;
margin-top: 12px;
margin-bottom: 24px;
}
}
}
.loadmore-divider {
height: 1px;
background-color: $gray-500;
flex: 1;
margin-left: 24px;
margin-right: 24px;
&:last-of-type {
margin-right: 0;
}
}
.loading {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
</style>
<script>
import moment from 'moment';
import axios from 'axios';
import debounce from 'lodash/debounce';
import { PerfectScrollbar } from 'vue2-perfect-scrollbar';
import { mapState } from '@/libs/store';
import Avatar from '../avatar';
import messageCard from './messageCard';
export default {
components: {
Avatar,
messageCard,
PerfectScrollbar,
},
props: {
chat: {},
isLoading: Boolean,
canLoadMore: Boolean,
},
data () {
return {
currentDayDividerDisplay: moment().day(),
cachedProfileData: {},
currentProfileLoadedCount: 0,
currentProfileLoadedEnd: 10,
loading: false,
handleScrollBack: false,
lastOffset: -1,
disablePerfectScroll: false,
};
},
mounted () {
this.loadProfileCache();
this.$el.addEventListener('selectstart', () => this.handleSelectStart());
this.$el.addEventListener('mouseup', () => this.handleSelectChange());
},
created () {
window.addEventListener('scroll', this.handleScroll);
},
beforeDestroy () {
this.$el.removeEventListener('selectstart', () => this.handleSelectStart());
this.$el.removeEventListener('mouseup', () => this.handleSelectChange());
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll);
},
computed: {
...mapState({ user: 'user.data' }),
// @TODO: We need a different lazy load mechnism.
// But honestly, adding a paging route to chat would solve this
messages () {
this.loadProfileCache();
return this.chat;
},
psOptions () {
return {
suppressScrollX: true,
};
},
},
methods: {
handleScroll () {
this.loadProfileCache(window.scrollY / 1000);
},
async triggerLoad () {
const container = this.$refs.container.$el;
// get current offset
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll
// container.style.overflowY = 'hidden';
const canLoadMore = !this.isLoading && this.canLoadMore;
if (canLoadMore) {
const triggerLoadResult = this.$emit('triggerLoad');
await triggerLoadResult;
this.handleScrollBack = true;
}
},
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
this._loadProfileCache(screenPosition);
}, 1000),
async _loadProfileCache (screenPosition) {
if (this.loading) return;
this.loading = true;
const promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination
// @TODO: Remove this after enough messages are cached
if (!noProfilesLoaded && screenPosition
&& Math.floor(screenPosition) + 1 > this.currentProfileLoadedEnd / 10) {
this.currentProfileLoadedEnd = 10 * (Math.floor(screenPosition) + 1);
} else if (!noProfilesLoaded && screenPosition) {
return;
}
const aboutToCache = {};
this.messages.forEach(message => {
const { uuid } = message;
if (message.userStyles) {
this.$set(this.cachedProfileData, uuid, message.userStyles);
}
if (Boolean(uuid) && !this.cachedProfileData[uuid] && !aboutToCache[uuid]) {
if (uuid === 'system' || this.currentProfileLoadedCount === this.currentProfileLoadedEnd) return;
aboutToCache[uuid] = {};
promises.push(axios.get(`/api/v4/members/${uuid}`));
this.currentProfileLoadedCount += 1;
}
});
const results = await Promise.all(promises);
results.forEach(result => {
// We could not load the user. Maybe they were deleted.
// So, let's cache empty so we don't try again
if (!result || !result.data || result.status >= 400) {
return;
}
const userData = result.data.data;
this.$set(this.cachedProfileData, userData._id, userData);
});
// Merge in any attempts that were rejected so we don't attempt again
for (const uuid in aboutToCache) {
if (!this.cachedProfileData[uuid]) {
this.$set(this.cachedProfileData, uuid, { rejected: true });
}
}
this.loading = false;
},
displayDivider (message) {
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
this.currentDayDividerDisplay = moment(message.timestamp).day();
return true;
}
return false;
},
async showMemberModal (memberId) {
let profile = this.cachedProfileData[memberId];
if (!profile._id) {
const result = await this.$store.dispatch('members:fetchMember', { memberId });
if (result.response && result.response.status === 404) {
return this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('messageDeletedUser'),
type: 'error',
timeout: false,
});
}
this.cachedProfileData[memberId] = result.data.data;
profile = result.data.data;
}
// Open the modal only if the data is available
if (profile && !profile.rejected) {
this.$router.push({ name: 'userProfile', params: { userId: profile._id } });
}
return null;
},
itemWasMounted: debounce(function itemWasMounted () {
if (this.handleScrollBack) {
this.handleScrollBack = false;
const container = this.$refs.container.$el;
const offset = container.scrollHeight - container.clientHeight;
const newOffset = offset + this.lastOffset;
container.scrollTo(0, newOffset);
// enable scroll again
// container.style.overflowY = 'scroll';
}
}, 50),
messageRemoved (message) {
this.$emit('message-removed', message);
},
handleSelectStart () {
this.disablePerfectScroll = true;
},
handleSelectChange () {
this.disablePerfectScroll = false;
},
},
};
</script>
@@ -28,39 +28,38 @@
<small>{{ $t('unsubscribeAllEmailsText') }}</small>
</div>
<div class="col-8">
<table class="table"></table>
<tr>
<td></td>
<th>
<span>{{ $t('email') }}</span>
</th>
<th>
<span>{{ $t('push') }}</span>
</th>
</tr>
<tr
v-for="notification in notificationsIds"
:key="notification"
>
<td>
<span>{{ $t(notification) }}</span>
</td>
<td>
<input
v-model="user.preferences.emailNotifications[notification]"
type="checkbox"
@change="set('emailNotifications', notification)"
>
</td>
<td v-if="onlyEmailsIds.indexOf(notification) === -1">
<input
v-model="user.preferences.pushNotifications[notification]"
type="checkbox"
@change="set('pushNotifications', notification)"
>
<hr>
</td>
</tr>
<table class="table">
<tr>
<td></td>
<th>
<span>{{ $t('email') }}</span>
</th>
<th>
<span>{{ $t('push') }}</span>
</th>
</tr>
<tr v-for="notification in notificationsIds" :key="notification">
<td>
<span>{{ $t(notification) }}</span>
</td>
<td>
<input
v-model="user.preferences.emailNotifications[notification]"
type="checkbox"
@change="set('emailNotifications', notification)"
>
</td>
<td v-if="onlyEmailsIds.indexOf(notification) === -1">
<input
v-model="user.preferences.pushNotifications[notification]"
type="checkbox"
@change="set('pushNotifications', notification)"
>
<td v-else>
&nbsp;
</td>
</tr>
</table>
</div>
</div>
</template>
@@ -74,6 +73,8 @@ export default {
data () {
return {
notificationsIds: Object.freeze([
'majorUpdates',
'onboarding',
'newPM',
'wonChallenge',
'giftedGems',
@@ -85,8 +86,6 @@ export default {
'invitedQuest',
'importantAnnouncements',
'weeklyRecaps',
'onboarding',
'majorUpdates',
'subscriptionReminders',
]),
// list of email-only notifications
@@ -812,7 +812,8 @@ export default {
this.localAuth.username = this.user.auth.local.username;
this.user.flags.verifiedUsername = true;
} else if (attribute === 'email') {
this.user.auth.local.email = updates[attribute];
this.user.auth.local.email = updates.newEmail;
window.alert(this.$t('emailSuccess'));
}
},
async changeDisplayName (newName) {
@@ -1,8 +1,8 @@
<template>
<b-modal
id="task-modal"
:no-close-on-esc="showTagsSelect"
:no-close-on-backdrop="showTagsSelect"
:no-close-on-esc="true"
:no-close-on-backdrop="true"
size="sm"
@hidden="onClose()"
@show="handleOpen()"
@@ -1,9 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import { withKnobs, number } from '@storybook/addon-knobs';
import CountBadge from './countBadge.vue';
storiesOf('Count Badge', module)
const stories = storiesOf('Count Badge', module);
stories.addDecorator(withKnobs);
stories
.add('simple', () => ({
components: { CountBadge },
template: `
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
<count-badge :count="count" :show="true"></count-badge>
</div>
`,
data () {
return {
count: 3,
};
props: {
count: {
default: number('Count', 3),
},
},
}));
@@ -2,14 +2,14 @@
<div class="popover-box">
<div
:id="containerId"
class="clearfix"
class="clearfix toggle-switch-outer"
>
<div
v-if="label"
class="float-left toggle-switch-description"
:class="hoverText ? 'hasPopOver' : ''"
>
{{ label }}
<span>{{ label }}</span>
</div>
<div class="toggle-switch float-left">
<input
@@ -53,9 +53,7 @@
}
.toggle-switch-description {
height: 20px;
&.hasPopOver {
&.hasPopOver span {
border-bottom: 1px dashed $gray-200;
}
}
+115
View File
@@ -0,0 +1,115 @@
<template>
<div
v-if="displayName"
v-b-tooltip.hover.top="tierTitle"
class="user-label"
:class="levelStyle()"
>
{{ displayName }}
<div
class="svg-icon"
v-html="tierIcon()"
></div>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
.user-label.no-tier {
color: $gray-50;
}
.user-label {
font-weight: bold;
margin-bottom: 0;
display: inline-block;
font-size: 16px;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
</style>
<script>
import achievementsLib from '@/../../common/script/libs/achievements';
import styleHelper from '@/mixins/styleHelper';
import tier1 from '@/assets/svg/tier-1.svg';
import tier2 from '@/assets/svg/tier-2.svg';
import tier3 from '@/assets/svg/tier-3.svg';
import tier4 from '@/assets/svg/tier-4.svg';
import tier5 from '@/assets/svg/tier-5.svg';
import tier6 from '@/assets/svg/tier-6.svg';
import tier7 from '@/assets/svg/tier-7.svg';
import tier8 from '@/assets/svg/tier-mod.svg';
import tier9 from '@/assets/svg/tier-staff.svg';
import tierNPC from '@/assets/svg/tier-npc.svg';
export default {
mixins: [styleHelper],
props: ['user', 'name', 'backer', 'contributor', 'hideTooltip'],
data () {
return {
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
};
},
computed: {
displayName () {
if (this.name) {
return this.name;
}
if (this.user && this.user.profile) {
return this.user.profile.name;
}
return null;
},
level () {
if (this.contributor) {
return this.contributor.level;
} if (this.user && this.user.contributor) {
return this.user.contributor.level;
}
return 0;
},
isNPC () {
if (this.backer) {
return this.backer.level;
} if (this.user && this.user.backer) {
return this.user.backer.level;
}
return false;
},
},
methods: {
tierIcon () {
if (this.isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${this.level}`];
},
tierTitle () {
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
},
levelStyle () {
return this.userLevelStyleFromLevel(this.level, this.isNPC);
},
},
};
</script>
@@ -1,698 +0,0 @@
<template>
<b-modal
id="inbox-modal"
title
:hide-footer="true"
size="lg"
@shown="onModalShown"
@hide="onModalHide"
>
<div
slot="modal-header"
class="header-wrap container align-items-center"
>
<div class="row align-items-center">
<div class="col-4">
<div class="row align-items-center">
<div class="col-2">
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
</div>
<div class="col-6">
<h2
v-once
class="text-center"
>
{{ $t('messages') }}
</h2>
</div>
</div>
</div>
<div class="col-4 offset-3">
<toggle-switch
class="float-right"
:label="optTextSet.switchDescription"
:checked="!user.inbox.optOut"
:hover-text="optTextSet.popoverText"
@change="toggleOpt()"
/>
</div>
<div class="col-1">
<div class="close">
<span
class="svg-icon inline icon-10"
aria-hidden="true"
@click="close()"
v-html="icons.svgClose"
></span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-4 sidebar">
<div class="search-section">
<b-form-input
v-model="search"
:placeholder="$t('search')"
/>
</div>
<div
v-if="filtersConversations.length === 0"
class="empty-messages text-center"
>
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4 v-once>
{{ $t('emptyMessagesLine1') }}
</h4>
<p v-if="!user.flags.chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
<div
v-if="filtersConversations.length > 0"
class="conversations"
>
<div
v-for="conversation in filtersConversations"
:key="conversation.key"
class="conversation"
:class="{active: selectedConversation.key === conversation.key}"
@click="selectConversation(conversation.key)"
>
<div>
<h3 :class="userLevelStyle(conversation)">
{{ conversation.name }}
<div
class="svg-icon"
v-html="tierIcon(conversation)"
></div>
</h3>
</div>
<div class="time">
<span
v-if="conversation.username"
class="mr-1"
>@{{ conversation.username }} </span>
<span v-if="conversation.date">{{ conversation.date | timeAgo }}</span>
</div>
<div
class="messagePreview"
>
{{ conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
</div>
</div>
</div>
</div>
<div class="col-8 messages d-flex flex-column justify-content-between">
<div
v-if="!selectedConversation.key"
class="empty-messages text-center"
>
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4>{{ placeholderTexts.title }}</h4>
<p v-html="placeholderTexts.description"></p>
</div>
<div
v-if="selectedConversation && selectedConversationMessages.length === 0"
class="empty-messages text-center"
>
<p>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</p>
</div>
<chat-messages
v-if="selectedConversation && selectedConversationMessages.length > 0"
ref="chatscroll"
class="message-scroll"
:chat="selectedConversationMessages"
:inbox="true"
:can-load-more="canLoadMore"
:is-loading="messagesLoading"
@message-removed="messageRemoved"
@triggerLoad="infiniteScrollTrigger"
/>
<div
v-if="user.inbox.optOut && selectedConversation.key"
class="pm-disabled-caption text-center"
>
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
<p>{{ $t('PMDisabledCaptionText') }}</p>
</div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="new-message-row"
>
<textarea
v-model="newMessage"
maxlength="3000"
@keyup.ctrl.enter="sendPrivateMessage()"
></textarea>
<button
class="btn btn-secondary"
@click="sendPrivateMessage()"
>
{{ $t('send') }}
</button>
<div class="row">
<span class="ml-3">{{ currentLength }} / 3000</span>
</div>
</div>
</div>
</div>
</b-modal>
</template>
<style lang="scss">
#inbox-modal .modal-body {
padding-top: 0px;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.header-wrap {
padding: 0.5em;
h2 {
margin: 0;
line-height: 1;
}
}
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.envelope {
color: $gray-400 !important;
margin: 0;
}
.sidebar {
background-color: $gray-700;
min-height: 540px;
padding: 0;
.search-section {
padding: 1em;
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
}
}
.messages {
position: relative;
padding-left: 0;
padding-bottom: 6em;
height: 540px;
}
.message-scroll {
max-height: 500px;
overflow-x: scroll;
@media (min-width: 992px) {
overflow-x: hidden;
overflow-y: scroll;
}
}
.to-form input {
width: 60%;
display: inline-block;
margin-left: 1em;
}
.empty-messages {
margin-top: 10em;
color: $gray-400;
padding: 1em;
h4 {
color: $gray-400;
margin-top: 1em;
}
.envelope {
width: 30px;
margin: 0 auto;
}
}
.pm-disabled-caption {
padding-top: 1em;
background-color: $gray-700;
z-index: 2;
h4, p {
color: $gray-300;
}
h4 {
margin-top: 0;
margin-bottom: 0.4em;
}
p {
font-size: 12px;
margin-bottom: 0;
}
}
.new-message-row {
background-color: $gray-700;
position: absolute;
bottom: 0;
height: 88px;
width: 100%;
padding: 1em;
textarea {
height: 80%;
display: inline-block;
vertical-align: bottom;
width: 80%;
}
button {
vertical-align: bottom;
display: inline-block;
box-shadow: none;
margin-left: 1em;
}
}
.conversations {
max-height: 400px;
overflow-x: hidden;
overflow-y: scroll;
}
.conversation {
padding: 1.5em;
background: $white;
}
.conversation.active {
border: 1px solid $purple-400;
}
.conversation:hover {
cursor: pointer;
}
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.messagePreview {
display: block;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
</style>
<script>
import Vue from 'vue';
import moment from 'moment';
import filter from 'lodash/filter';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import habiticaMarkdown from 'habitica-markdown';
import axios from 'axios';
import { mapState } from '@/libs/store';
import styleHelper from '@/mixins/styleHelper';
import toggleSwitch from '@/components/ui/toggleSwitch';
import chatMessages from '../chat/chatMessages';
import messageIcon from '@/assets/svg/message.svg';
import svgClose from '@/assets/svg/close.svg';
import tier1 from '@/assets/svg/tier-1.svg';
import tier2 from '@/assets/svg/tier-2.svg';
import tier3 from '@/assets/svg/tier-3.svg';
import tier4 from '@/assets/svg/tier-4.svg';
import tier5 from '@/assets/svg/tier-5.svg';
import tier6 from '@/assets/svg/tier-6.svg';
import tier7 from '@/assets/svg/tier-7.svg';
import tier8 from '@/assets/svg/tier-mod.svg';
import tier9 from '@/assets/svg/tier-staff.svg';
import tierNPC from '@/assets/svg/tier-npc.svg';
export default {
components: {
chatMessages,
toggleSwitch,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper],
data () {
return {
icons: Object.freeze({
messageIcon,
svgClose,
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
displayCreate: true,
selectedConversation: {},
search: '',
newMessage: '',
showPopover: false,
messages: [],
messagesByConversation: {}, // cache {uuid: []}
loadedConversations: [],
loaded: false,
messagesLoading: false,
initiatedConversation: null,
updateConversionsCounter: 0,
};
},
computed: {
...mapState({ user: 'user.data' }),
canLoadMore () {
return this.selectedConversation && this.selectedConversation.canLoadMore;
},
conversations () {
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
// Add placeholder for new conversations
if (this.initiatedConversation && this.initiatedConversation.uuid) {
inboxGroup[this.initiatedConversation.uuid] = [{
uuid: this.initiatedConversation.uuid,
user: this.initiatedConversation.user,
username: this.initiatedConversation.username,
contributor: this.initiatedConversation.contributor,
id: '',
text: '',
timestamp: new Date(),
}];
}
// Create conversation objects
const convos = [];
for (const key of Object.keys(inboxGroup)) {
const recentMessage = inboxGroup[key][0];
const convoModel = {
key: recentMessage.uuid,
// Handles case where from user sent
// the only message or the to user sent the only message
name: recentMessage.user,
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
canLoadMore: true,
page: 0,
};
convos.push(convoModel);
}
return convos;
},
// Separate from selectedConversation which
// is not computed so messages don't update automatically
selectedConversationMessages () {
// Vue-subscribe to changes
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
const selectedConversationKey = this.selectedConversation.key;
const selectedConversation = this.messagesByConversation[selectedConversationKey];
this.messages = selectedConversation || []; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
if (subScribeToUpdate) {
return ordered;
}
return [];
},
filtersConversations () {
// Vue-subscribe to changes
const subScribeToUpdate = this.updateConversionsCounter > -1;
const filtered = subScribeToUpdate && !this.search
? this.conversations
: filter(
this.conversations,
conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1,
);
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
return ordered;
},
currentLength () {
return this.newMessage.length;
},
placeholderTexts () {
if (this.user.flags.chatRevoked) {
return {
title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('chatPrivilegesRevoked'),
};
}
return {
title: this.$t('PMPlaceholderTitle'),
description: this.$t('PMPlaceholderDescription'),
};
},
optTextSet () {
if (!this.user.inbox.optOut) {
return {
switchDescription: this.$t('PMReceive'),
popoverText: this.$t('PMEnabledOptPopoverText'),
};
}
return {
switchDescription: this.$t('PMReceive'),
popoverText: this.$t('PMDisabledOptPopoverText'),
};
},
},
mounted () {
this.$root.$on('habitica::new-inbox-message', data => {
this.$root.$emit('bv::show::modal', 'inbox-modal');
// Wait for messages to be loaded
const unwatchLoaded = this.$watch('loaded', loaded => {
if (!loaded) return;
const conversation = this.conversations.find(convo => convo.key === data.userIdToMessage);
if (loaded) setImmediate(() => unwatchLoaded());
if (conversation) {
this.selectConversation(data.userIdToMessage);
return;
}
this.initiatedConversation = {
uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
};
this.selectConversation(data.userIdToMessage);
}, { immediate: true });
});
},
destroyed () {
this.$root.$off('habitica::new-inbox-message');
},
methods: {
async onModalShown () {
this.loaded = false;
const conversationRes = await axios.get('/api/v4/inbox/conversations');
this.loadedConversations = conversationRes.data.data;
this.loaded = true;
},
onModalHide () {
// reset everything
this.loadedConversations = [];
this.loaded = false;
this.initiatedConversation = null;
this.messagesByConversation = {};
this.selectedConversation = {};
},
messageRemoved (message) {
const messages = this.messagesByConversation[this.selectedConversation.key];
const messageIndex = messages.findIndex(msg => msg.id === message.id);
if (messageIndex !== -1) messages.splice(messageIndex, 1);
if (this.selectedConversationMessages.length === 0) {
this.initiatedConversation = {
uuid: this.selectedConversation.key,
user: this.selectedConversation.name,
username: this.selectedConversation.username,
backer: this.selectedConversation.backer,
contributor: this.selectedConversation.contributor,
};
}
},
toggleClick () {
this.displayCreate = !this.displayCreate;
},
toggleOpt () {
this.$store.dispatch('user:togglePrivateMessagesOpt');
},
async selectConversation (key) {
const convoFound = this.conversations.find(conversation => conversation.key === key);
this.selectedConversation = convoFound || {};
if (!this.messagesByConversation[this.selectedConversation.key]) {
await this.loadMessages();
}
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
},
sendPrivateMessage () {
if (!this.newMessage) return;
const messages = this.messagesByConversation[this.selectedConversation.key];
messages.push({
sent: true,
text: this.newMessage,
timestamp: new Date(),
toUser: this.selectedConversation.name,
toUserName: this.selectedConversation.username,
toUserContributor: this.selectedConversation.contributor,
toUserBacker: this.selectedConversation.backer,
toUUID: this.selectedConversation.uuid,
id: '-1', // will be updated once the result is back
likes: {},
ownerId: this.user._id,
uuid: this.user._id,
fromUUID: this.user._id,
user: this.user.profile.name,
username: this.user.auth.local.username,
contributor: this.user.contributor,
backer: this.user.backer,
});
// Remove the placeholder message
if (
this.initiatedConversation
&& this.initiatedConversation.uuid === this.selectedConversation.key
) {
this.loadedConversations.unshift(this.initiatedConversation);
this.initiatedConversation = null;
}
this.selectedConversation.lastMessageText = this.newMessage;
this.selectedConversation.date = new Date();
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
this.$store.dispatch('members:sendPrivateMessage', {
toUserId: this.selectedConversation.key,
message: this.newMessage,
}).then(response => {
const newMessage = response.data.data.message;
const messageToReset = messages[messages.length - 1];
messageToReset.id = newMessage.id; // just set the id, all other infos already set
Object.assign(messages[messages.length - 1], messageToReset);
this.updateConversionsCounter += 1;
});
this.newMessage = '';
},
close () {
this.$root.$emit('bv::hide::modal', 'inbox-modal');
},
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
if (!message.contributor) return null;
return this.icons[`tier${message.contributor.level}`];
},
removeTags (html) {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading)
if (this.canLoadMore) {
this.messagesLoading = true;
}
return this.loadMore();
},
loadMore () {
this.selectedConversation.page += 1;
return this.loadMessages();
},
async loadMessages () {
this.messagesLoading = true;
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${this.selectedConversation.key}&page=${this.selectedConversation.page}`;
const res = await axios.get(requestUrl);
const loadedMessages = res.data.data;
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || []; // eslint-disable-line max-len
const loadedMessagesToAdd = loadedMessages
.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1); // eslint-disable-line max-len
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
// only show the load more Button if the max count was returned
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
this.messagesLoading = false;
},
},
};
</script>
@@ -859,13 +859,12 @@ export default {
window.history.replaceState(null, null, '');
},
sendMessage () {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: this.user._id,
displayName: this.user.profile.name,
username: this.user.auth.local.username,
backer: this.user.backer,
contributor: this.user.contributor,
this.$store.dispatch('user:newPrivateMessageTo', {
member: this.user,
});
this.$router.push('/private-messages');
this.$root.$emit('bv::hide::modal', 'profile');
},
getProgressDisplay () {
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];
+1 -1
View File
@@ -29,7 +29,7 @@ export function setUpLogging () { // eslint-disable-line import/prefer-default-e
Vue.config.errorHandler = (err, vm, info) => {
console.error('Unhandled error in Vue.js code.');
console.error('Error:', err);
console.error('Component where it occured:', vm);
console.error('Component where it occurred:', vm);
console.error('Info:', info);
_LTracker.push({
+1 -1
View File
@@ -165,7 +165,7 @@ export default {
}
return null;
// @TOOD: User.sync();
// @TODO: User.sync();
},
castCancel () {
this.potionClickMode = false;
@@ -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>
+3
View File
@@ -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',
+4 -1
View File
@@ -60,7 +60,10 @@ async function buyArmoire (store, params) {
const isExperience = item.type === 'experience';
if (item.type === 'gear') {
store.state.user.data.items.gear.owned[item.dropKey] = true;
store.state.user.data.items.gear.owned = {
...store.state.user.data.items.gear.owned,
[item.dropKey]: true,
};
}
if (item.type === 'food') {
+62
View File
@@ -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,
};
}
+7
View File
@@ -127,6 +127,13 @@ export default function () {
equipmentDrawerOpen: true,
groupPlans: [],
isRunningYesterdailies: false,
privateMessageOptions: {
userIdToMessage: '',
displayName: '',
username: '',
backer: {},
contributor: {},
},
},
});