Fixing layout issues for the private messages page (#11766)

* fix: first batch of layout issues for private messages + auto sizing textarea

* username second line - open profile on face-avatar/conversation name - fix textarea height

* refresh on sync

* new "you dont have any messages" style + changed min textarea height

* new conversationItem style / layout

* reset message unread on reload

* fix styles / textarea height

* list optOut / chatRevoked informations for each conversation + show why its disabled

* Block / Unblock - correct disabled states - $gray-200 instead of 300/400

* canReceive not checking chatRevoked

* fix: faceAvatar / userLink open the selected conversation user

* check if the target user is blocking the logged-in user

* check if blocks is undefined

* max-height instead of height

* fix "no messages" state + canReceive on a new conversation

* fixed conversations width (280px on max 768 width page)

* call autosize after message is sent

* only color the placeholder

* only load the current user avatar/settings/flags

* show only the current avatar on private messages
This commit is contained in:
negue
2020-03-04 17:50:08 +01:00
committed by GitHub
parent 2ff9dfe965
commit fe6c21800c
16 changed files with 317 additions and 280 deletions

View File

@@ -23,7 +23,7 @@ input, textarea, input.form-control, textarea.form-control {
border-radius: 2px; border-radius: 2px;
font-size: 14px; font-size: 14px;
line-height: 1.43; line-height: 1.43;
color: $gray-200; color: $gray-50;
border: 1px solid $gray-400; border: 1px solid $gray-400;
&:hover:not(:disabled) { &:hover:not(:disabled) {
@@ -32,7 +32,6 @@ input, textarea, input.form-control, textarea.form-control {
&:active:not(:disabled), &:focus:not(:disabled) { &:active:not(:disabled), &:focus:not(:disabled) {
border-color: $purple-500; border-color: $purple-500;
color: $gray-50;
outline: 0; outline: 0;
box-shadow: none; box-shadow: none;
} }

View File

@@ -133,13 +133,12 @@
</style> </style>
<script> <script>
import axios from 'axios';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics'; import * as Analytics from '@/libs/analytics';
import userIcon from '@/assets/svg/user.svg'; import userIcon from '@/assets/svg/user.svg';
import MenuDropdown from '../ui/customMenuDropdown'; import MenuDropdown from '../ui/customMenuDropdown';
import markPMSRead from '@/../../common/script/ops/markPMSRead';
import MessageCount from './messageCount'; import MessageCount from './messageCount';
import { EVENTS } from '@/libs/events';
export default { export default {
components: { components: {
@@ -164,11 +163,8 @@ export default {
this.$root.$emit('bv::show::modal', 'avatar-modal'); this.$root.$emit('bv::show::modal', 'avatar-modal');
}, },
showPrivateMessages () { showPrivateMessages () {
markPMSRead(this.user);
axios.post('/api/v4/user/mark-pms-read');
if (this.$router.history.current.name === 'privateMessages') { if (this.$router.history.current.name === 'privateMessages') {
this.$root.$emit('pm::refresh'); this.$root.$emit(EVENTS.PM_REFRESH);
} else { } else {
this.$router.push('/private-messages'); this.$router.push('/private-messages');
} }

View File

@@ -10,21 +10,7 @@
:backer="backer" :backer="backer"
:contributor="contributor" :contributor="contributor"
:name="displayName" :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 <div
v-if="userLoggedIn.id !== uuid" v-if="userLoggedIn.id !== uuid"
class="actions" class="actions"
@@ -44,16 +30,34 @@
v-html="icons.dots" v-html="icons.dots"
></div> ></div>
</template> </template>
<b-dropdown-item @click="block()"> <b-dropdown-item @click="toggleBlock()">
<span class="dropdown-icon-item"> <span class="dropdown-icon-item">
<div <div
class="svg-icon inline" class="svg-icon inline"
v-html="icons.remove" v-html="icons.remove"
></div><span class="text">{{ $t('block') }}</span></span> ></div><span class="text">{{ $t(isBlocked ? 'unblock' : 'block') }}</span></span>
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
</div> </div>
</div> </div>
<span class="username-row">
<span
v-if="username"
class="username"
>@{{ username }}</span> <span
v-if="lastMessageDate"
class="time"
>
{{ lastMessageDate | timeAgo }}
</span>
</span>
<div class="preview-row">
<div class="messagePreview">
{{ lastMessageText }}
</div>
</div>
</div> </div>
</template> </template>
@@ -79,6 +83,9 @@ export default {
...mapState({ ...mapState({
userLoggedIn: 'user.data', userLoggedIn: 'user.data',
}), }),
isBlocked () {
return this.userLoggedIn.inbox.blocks.includes(this.uuid);
},
}, },
data () { data () {
return { return {
@@ -101,8 +108,8 @@ export default {
dropdown.hide(); dropdown.hide();
} }
}, },
block () { toggleBlock () {
this.$store.dispatch('user:block', { this.$store.dispatch(this.isBlocked ? 'user:unblock' : 'user:block', {
uuid: this.uuid, uuid: this.uuid,
}); });
}, },
@@ -133,6 +140,12 @@ export default {
height: 16px; height: 16px;
width: 4px; width: 4px;
&:not(:hover) {
svg path {
fill: $gray-200;
}
}
svg path { svg path {
fill: $purple-300 fill: $purple-300
} }
@@ -144,7 +157,7 @@ export default {
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
.conversation { .conversation {
padding: 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid $gray-500; border-bottom: 1px solid $gray-500;
&:hover { &:hover {
@@ -164,6 +177,7 @@ export default {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 20px; height: 20px;
position: relative;
.user-label { .user-label {
flex: 1; flex: 1;
@@ -172,11 +186,6 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.username {
flex: 1;
flex-grow: 0;
}
.time { .time {
flex: 2; flex: 2;
text-align: end; text-align: end;
@@ -187,10 +196,15 @@ export default {
} }
} }
.username-row {
flex: 1;
flex-grow: 0;
font-size: 12px;
color: $gray-200;
}
.messagePreview { .messagePreview {
//width: 100%; max-height: 30px;
height: 30px;
margin-right: 40px;
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
font-weight: normal; font-weight: normal;
@@ -221,7 +235,6 @@ export default {
right: 0; right: 0;
display: none; display: none;
width: 16px; width: 16px;
margin-top: 4px;
.dots { .dots {
height: 16px; height: 16px;

View File

@@ -34,10 +34,9 @@
class="d-flex flex-grow-1" class="d-flex flex-grow-1"
> >
<avatar <avatar
v-if="msg.userStyles || (cachedProfileData[msg.uuid] v-if="conversationOpponentUser"
&& !cachedProfileData[msg.uuid].rejected)"
class="avatar-left" class="avatar-left"
:member="msg.userStyles || cachedProfileData[msg.uuid]" :member="conversationOpponentUser"
:avatar-only="true" :avatar-only="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
:hide-class-badge="true" :hide-class-badge="true"
@@ -65,10 +64,9 @@
/> />
</div> </div>
<avatar <avatar
v-if="msg.userStyles v-if="user"
|| (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)"
class="avatar-right" class="avatar-right"
:member="msg.userStyles || cachedProfileData[msg.uuid]" :member="user"
:avatar-only="true" :avatar-only="true"
:hide-class-badge="true" :hide-class-badge="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
@@ -88,18 +86,18 @@
} }
.avatar { .avatar {
width: 15%; width: 170px;
min-width: 8rem; min-width: 8rem;
height: 120px; height: 120px;
padding-top: 0 !important; padding-top: 0 !important;
} }
.avatar-left {
margin-left: -1rem;
}
.avatar-right { .avatar-right {
margin-left: -1rem; margin-left: -1rem;
::v-deep .character-sprites {
margin-right: 1rem !important;
}
} }
.card { .card {
@@ -201,7 +199,6 @@
<script> <script>
import moment from 'moment'; import moment from 'moment';
import axios from 'axios';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { PerfectScrollbar } from 'vue2-perfect-scrollbar'; import { PerfectScrollbar } from 'vue2-perfect-scrollbar';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
@@ -219,13 +216,11 @@ export default {
chat: {}, chat: {},
isLoading: Boolean, isLoading: Boolean,
canLoadMore: Boolean, canLoadMore: Boolean,
conversationOpponentUser: {},
}, },
data () { data () {
return { return {
currentDayDividerDisplay: moment().day(), currentDayDividerDisplay: moment().day(),
cachedProfileData: {},
currentProfileLoadedCount: 0,
currentProfileLoadedEnd: 10,
loading: false, loading: false,
handleScrollBack: false, handleScrollBack: false,
lastOffset: -1, lastOffset: -1,
@@ -233,8 +228,6 @@ export default {
}; };
}, },
mounted () { mounted () {
this.loadProfileCache();
this.$el.addEventListener('selectstart', () => this.handleSelectStart()); this.$el.addEventListener('selectstart', () => this.handleSelectStart());
this.$el.addEventListener('mouseup', () => this.handleSelectChange()); this.$el.addEventListener('mouseup', () => this.handleSelectChange());
}, },
@@ -253,7 +246,6 @@ export default {
// @TODO: We need a different lazy load mechnism. // @TODO: We need a different lazy load mechnism.
// But honestly, adding a paging route to chat would solve this // But honestly, adding a paging route to chat would solve this
messages () { messages () {
this.loadProfileCache();
return this.chat; return this.chat;
}, },
psOptions () { psOptions () {
@@ -263,9 +255,6 @@ export default {
}, },
}, },
methods: { methods: {
handleScroll () {
this.loadProfileCache(window.scrollY / 1000);
},
async triggerLoad () { async triggerLoad () {
const container = this.$refs.container.$el; const container = this.$refs.container.$el;
@@ -284,62 +273,6 @@ export default {
this.handleScrollBack = true; 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) { displayDivider (message) {
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) { if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
this.currentDayDividerDisplay = moment(message.timestamp).day(); this.currentDayDividerDisplay = moment(message.timestamp).day();
@@ -348,29 +281,8 @@ export default {
return false; return false;
}, },
async showMemberModal (memberId) { showMemberModal (memberId) {
let profile = this.cachedProfileData[memberId]; this.$router.push({ name: 'userProfile', params: { userId: 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 () { itemWasMounted: debounce(function itemWasMounted () {
if (this.handleScrollBack) { if (this.handleScrollBack) {

View File

@@ -157,6 +157,7 @@
import axios from 'axios'; import axios from 'axios';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import { EVENTS } from '@/libs/events';
export default { export default {
props: { props: {
@@ -218,9 +219,9 @@ export default {
}, },
methods: { methods: {
async close () { async close () {
this.$root.$emit('habitica::resync-requested'); this.$root.$emit(EVENTS.RESYNC_REQUESTED);
await this.$store.dispatch('user:fetch', { forceLoad: true }); await this.$store.dispatch('user:fetch', { forceLoad: true });
this.$root.$emit('habitica::resync-completed'); this.$root.$emit(EVENTS.RESYNC_COMPLETED);
if (this.avatarIntro) { if (this.avatarIntro) {
this.$emit('usernameConfirmed'); this.$emit('usernameConfirmed');
} else { } else {

View File

@@ -348,6 +348,7 @@ import habitIcon from '@/assets/svg/habit.svg';
import dailyIcon from '@/assets/svg/daily.svg'; import dailyIcon from '@/assets/svg/daily.svg';
import todoIcon from '@/assets/svg/todo.svg'; import todoIcon from '@/assets/svg/todo.svg';
import rewardIcon from '@/assets/svg/reward.svg'; import rewardIcon from '@/assets/svg/reward.svg';
import { EVENTS } from '@/libs/events';
export default { export default {
components: { components: {
@@ -500,7 +501,7 @@ export default {
}); });
if (this.type !== 'todo') return; if (this.type !== 'todo') return;
this.$root.$on('habitica::resync-completed', () => { this.$root.$on(EVENTS.RESYNC_COMPLETED, () => {
if (this.activeFilter.label !== 'complete2') return; if (this.activeFilter.label !== 'complete2') return;
this.loadCompletedTodos(); this.loadCompletedTodos();
}); });
@@ -508,7 +509,7 @@ export default {
destroyed () { destroyed () {
this.$root.$off('buyModal::boughtItem'); this.$root.$off('buyModal::boughtItem');
if (this.type !== 'todo') return; if (this.type !== 'todo') return;
this.$root.$off('habitica::resync-requested'); this.$root.$off(EVENTS.RESYNC_COMPLETED);
}, },
methods: { methods: {
...mapActions({ ...mapActions({

View File

@@ -2,7 +2,7 @@
import { storiesOf } from '@storybook/vue'; import { storiesOf } from '@storybook/vue';
import { text, withKnobs } from '@storybook/addon-knobs'; import { text, withKnobs } from '@storybook/addon-knobs';
const stories = storiesOf('Textare', module); const stories = storiesOf('Textarea', module);
stories.addDecorator(withKnobs); stories.addDecorator(withKnobs);
@@ -11,7 +11,11 @@ stories
components: { }, components: { },
template: ` template: `
<div style="position: absolute; margin: 20px"> <div style="position: absolute; margin: 20px">
<textarea autofocus ref="area">Normal {{text}}</textarea> <button @click="$refs.area.focus()">Focus</button> <textarea autofocus ref="area">Normal {{text}}</textarea>
<br />
<button class="btn btn-dark" @click="$refs.area.focus()">Focus ^</button>
<br />
<textarea placeholder="placeholder"></textarea>
<br /> <br />
<textarea disabled>Disabled {{text}}</textarea><br /> <textarea disabled>Disabled {{text}}</textarea><br />

View File

@@ -2,7 +2,7 @@
<router-link <router-link
v-if="displayName" v-if="displayName"
v-b-tooltip.hover.top="tierTitle" v-b-tooltip.hover.top="tierTitle"
class="leader" class="leader user-link"
:to="{'name': 'userProfile', 'params': {'userId': id}}" :to="{'name': 'userProfile', 'params': {'userId': id}}"
:class="levelStyle()" :class="levelStyle()"
> >
@@ -56,7 +56,7 @@ import tierNPC from '@/assets/svg/tier-npc.svg';
export default { export default {
mixins: [styleHelper], mixins: [styleHelper],
props: ['user', 'userId', 'name', 'backer', 'contributor'], props: ['user', 'userId', 'name', 'backer', 'contributor', 'hideTooltip'],
data () { data () {
return { return {
icons: Object.freeze({ icons: Object.freeze({
@@ -111,7 +111,7 @@ export default {
return this.icons[`tier${this.level}`]; return this.icons[`tier${this.level}`];
}, },
tierTitle () { tierTitle () {
return achievementsLib.getContribText(this.contributor, this.isNPC) || ''; return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
}, },
levelStyle () { levelStyle () {
return this.userLevelStyleFromLevel(this.level, this.isNPC); return this.userLevelStyleFromLevel(this.level, this.isNPC);

View File

@@ -0,0 +1,6 @@
export const EVENTS = {
RESYNC_REQUESTED: 'habitica::resync-requested',
RESYNC_COMPLETED: 'habitica::resync-completed',
PM_REFRESH: 'pm::refresh',
};

View File

@@ -1,12 +1,14 @@
import { EVENTS } from '@/libs/events';
export default { export default {
methods: { methods: {
async sync () { async sync () {
this.$root.$emit('habitica::resync-requested'); this.$root.$emit(EVENTS.RESYNC_REQUESTED);
await Promise.all([ await Promise.all([
this.$store.dispatch('user:fetch', { forceLoad: true }), this.$store.dispatch('user:fetch', { forceLoad: true }),
this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }), this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }),
]); ]);
this.$root.$emit('habitica::resync-completed'); this.$root.$emit(EVENTS.RESYNC_COMPLETED);
}, },
}, },
}; };

View File

@@ -3,7 +3,7 @@
<div class="floating-header-shadow"></div> <div class="floating-header-shadow"></div>
<div class="header-bar d-flex w-100"> <div class="header-bar d-flex w-100">
<!-- changing w-25 would also need changes in .left-header.w-25 --> <!-- changing w-25 would also need changes in .left-header.w-25 -->
<div class="d-flex w-25 left-header"> <div class="d-flex left-header">
<div <div
v-once v-once
class="mail-icon svg-icon" class="mail-icon svg-icon"
@@ -19,22 +19,28 @@
<!-- placeholder --> <!-- placeholder -->
</div> </div>
</div> </div>
<div class="d-flex w-75 selected-conversion"> <div class="d-flex selected-conversion">
<face-avatar <router-link
v-if="selectedConversation.userStyles" :to="{'name': 'userProfile', 'params': {'userId': selectedConversation.key}}"
:member="selectedConversation.userStyles" >
:class="selectedConversationFaceAvatarClass" <face-avatar
/> v-if="selectedConversation.userStyles"
<user-label :member="selectedConversation.userStyles"
:class="selectedConversationFaceAvatarClass"
/>
</router-link>
<user-link
:backer="selectedConversation.backer" :backer="selectedConversation.backer"
:contributor="selectedConversation.contributor" :contributor="selectedConversation.contributor"
:name="selectedConversation.name" :name="selectedConversation.name"
:user="selectedConversation"
:userId="selectedConversation.key"
:hide-tooltip="true" :hide-tooltip="true"
/> />
</div> </div>
</div> </div>
<div class="d-flex content"> <div class="d-flex content">
<div class="w-25 sidebar d-flex flex-column"> <div class="sidebar d-flex flex-column">
<div class="disable-background"> <div class="disable-background">
<toggle-switch <toggle-switch
:label="optTextSet.switchDescription" :label="optTextSet.switchDescription"
@@ -50,24 +56,6 @@
:placeholder="$t('search')" :placeholder="$t('search')"
/> />
</div> </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 <div
v-if="filtersConversations.length > 0" v-if="filtersConversations.length > 0"
class="conversations" class="conversations"
@@ -88,18 +76,39 @@
/> />
</div> </div>
</div> </div>
<div class="w-75 messages-column d-flex flex-column align-items-center"> <div class="messages-column d-flex flex-column align-items-center">
<div <div
v-if="!selectedConversation.key" v-if="filtersConversations.length === 0
&& (!selectedConversation || !selectedConversation.key)"
class="empty-messages m-auto text-center empty-sidebar"
>
<div class="no-messages-box">
<div
v-once
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h2 v-once>
{{ $t('emptyMessagesLine1') }}
</h2>
<p v-if="!user.flags.chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
</div>
<div
v-if="filtersConversations.length !== 0 && !selectedConversation.key"
class="empty-messages full-height m-auto text-center" class="empty-messages full-height m-auto text-center"
> >
<div <div class="no-messages-box">
v-once <div
class="svg-icon envelope" v-once
v-html="icons.messageIcon" class="svg-icon envelope"
></div> v-html="icons.messageIcon"
<h4>{{ placeholderTexts.title }}</h4> ></div>
<p v-html="placeholderTexts.description"></p> <h2>{{ placeholderTexts.title }}</h2>
<p v-html="placeholderTexts.description"></p>
</div>
</div> </div>
<div <div
v-if="selectedConversation.key && selectedConversationMessages.length === 0" v-if="selectedConversation.key && selectedConversationMessages.length === 0"
@@ -120,34 +129,37 @@
ref="chatscroll" ref="chatscroll"
class="message-scroll" class="message-scroll"
:chat="selectedConversationMessages" :chat="selectedConversationMessages"
:conversationOpponentUser="selectedConversation.userStyles"
:can-load-more="canLoadMore" :can-load-more="canLoadMore"
:is-loading="messagesLoading" :is-loading="messagesLoading"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@triggerLoad="infiniteScrollTrigger" @triggerLoad="infiniteScrollTrigger"
/> />
<div <div
v-if="user.inbox.optOut" v-if="disabledTexts"
class="pm-disabled-caption text-center" class="pm-disabled-caption text-center"
> >
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4> <h4>{{ disabledTexts.title }}</h4>
<p>{{ $t('PMDisabledCaptionText') }}</p> <p>{{ disabledTexts.description }}</p>
</div> </div>
<div> <div>
<div <div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="new-message-row d-flex align-items-center" class="new-message-row d-flex align-items-center"
> >
<textarea <textarea
ref="textarea"
v-model="newMessage" v-model="newMessage"
class="flex-fill" class="flex-fill"
:placeholder="$t('needsTextPlaceholder')" :placeholder="$t('needsTextPlaceholder')"
:maxlength="MAX_MESSAGE_LENGTH" :maxlength="MAX_MESSAGE_LENGTH"
:class="{'has-content': newMessage !== ''}" :class="{'has-content': newMessage !== '', 'disabled': newMessageDisabled}"
:style="{'--textarea-auto-height': textareaAutoHeight}"
@keyup.ctrl.enter="sendPrivateMessage()" @keyup.ctrl.enter="sendPrivateMessage()"
></textarea> @input="autoSize()"
>
</textarea>
</div> </div>
<div <div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="sub-new-message-row d-flex" class="sub-new-message-row d-flex"
> >
<div <div
@@ -157,7 +169,7 @@
></div> ></div>
<button <button
class="btn btn-primary" class="btn btn-primary"
:class="{'disabled':newMessage === ''}" :class="{'disabled':newMessageDisabled || newMessage === ''}"
@click="sendPrivateMessage()" @click="sendPrivateMessage()"
> >
{{ $t('send') }} {{ $t('send') }}
@@ -268,10 +280,6 @@
.placeholder.svg-icon { .placeholder.svg-icon {
width: 32px; width: 32px;
} }
.left-header.w-25 {
width: calc(25% - 2rem) !important;
}
} }
.full-height { .full-height {
@@ -281,7 +289,7 @@
justify-content: center; justify-content: center;
} }
.user-label { .user-link {
margin-left: 12px; margin-left: 12px;
} }
@@ -292,10 +300,13 @@
background-image: url(~@/assets/svg/for-css/search_gray.svg) !important; background-image: url(~@/assets/svg/for-css/search_gray.svg) !important;
padding-left: 40px; padding-left: 40px;
color: $gray-200 !important;
height: 40px; height: 40px;
} }
.input-search::placeholder {
color: $gray-200 !important;
}
.selected-conversion { .selected-conversion {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -314,32 +325,42 @@
.conversations { .conversations {
max-height: 35rem;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
} }
.empty-messages { .empty-messages {
h3, h4, p { h3, p {
color: $gray-400; color: $gray-200;
margin: 0rem; margin: 0rem;
} }
h2 {
color: $gray-200;
margin-bottom: 1rem;
}
p { p {
font-size: 12px; font-size: 12px;
} }
.envelope { .no-messages-box {
width: 30px; display: flex;
margin: 0 auto 0.5rem; flex-direction: column;
align-items: center;
width: 330px;
} }
}
.envelope { .envelope {
color: $gray-500 !important; color: $gray-400 !important;
margin: 0rem; margin-bottom: 1.5rem;
max-width: 2rem;
::v-deep svg {
width: 64px;
height: 48px;
}
}
} }
h3 { h3 {
@@ -371,7 +392,12 @@
word-break: break-word; word-break: break-word;
} }
.selected-conversion {
flex: 1;
}
.messages-column { .messages-column {
flex: 1;
padding: 0rem; padding: 0rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -398,16 +424,24 @@
padding-right: 1.5rem; padding-right: 1.5rem;
textarea { textarea {
height: 5.5rem;
display: inline-block; display: inline-block;
vertical-align: bottom; vertical-align: bottom;
border-radius: 2px; border-radius: 2px;
z-index: 5; z-index: 5;
&.disabled {
pointer-events: none;
opacity: 0.64;
background-color: $gray-500;
}
min-height: var(--textarea-auto-height, 40px);
max-height: var(--textarea-auto-height, 40px);
} }
} }
.sub-new-message-row { .sub-new-message-row {
padding: 1rem 1.5rem 1.5rem; padding: 1.5rem;
.guidelines { .guidelines {
height: 32px; height: 32px;
@@ -433,17 +467,17 @@
pointer-events: none; pointer-events: none;
opacity: 0.64; opacity: 0.64;
background-color: $gray-500; background-color: $gray-500;
color: $gray-100;
} }
} }
} }
.pm-disabled-caption { .pm-disabled-caption {
padding-top: 1em; padding-top: 1em;
background-color: $gray-700;
z-index: 2; z-index: 2;
h4, p { h4, p {
color: $gray-300; color: $gray-200;
} }
h4 { h4 {
@@ -457,10 +491,14 @@
} }
} }
.left-header {
max-width: calc(330px - 2rem); // minus the left padding
flex: 1;
}
.sidebar { .sidebar {
width: 330px;
background-color: $gray-700; background-color: $gray-700;
min-height: 540px;
max-width: 330px;
padding: 0; padding: 0;
border-bottom-left-radius: 8px; border-bottom-left-radius: 8px;
@@ -468,6 +506,10 @@
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid $gray-500; border-bottom: 1px solid $gray-500;
} }
@media only screen and (max-width: 768px) {
width: 280px;
}
} }
.time { .time {
@@ -522,7 +564,7 @@ import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import styleHelper from '@/mixins/styleHelper'; import styleHelper from '@/mixins/styleHelper';
import toggleSwitch from '@/components/ui/toggleSwitch'; import toggleSwitch from '@/components/ui/toggleSwitch';
import userLabel from '@/components/userLabel'; import userLink from '@/components/userLink';
import messageList from '@/components/messages/messageList'; import messageList from '@/components/messages/messageList';
import messageIcon from '@/assets/svg/message.svg'; import messageIcon from '@/assets/svg/message.svg';
@@ -530,6 +572,9 @@ import mail from '@/assets/svg/mail.svg';
import conversationItem from '@/components/messages/conversationItem'; import conversationItem from '@/components/messages/conversationItem';
import faceAvatar from '@/components/faceAvatar'; import faceAvatar from '@/components/faceAvatar';
import Avatar from '@/components/avatar'; import Avatar from '@/components/avatar';
import { EVENTS } from '@/libs/events';
const MAX_TEXTAREA_HEIGHT = 80;
export default { export default {
components: { components: {
@@ -537,7 +582,7 @@ export default {
messageList, messageList,
toggleSwitch, toggleSwitch,
conversationItem, conversationItem,
userLabel, userLink,
faceAvatar, faceAvatar,
}, },
filters: { filters: {
@@ -564,14 +609,23 @@ export default {
messagesLoading: false, messagesLoading: false,
initiatedConversation: null, initiatedConversation: null,
updateConversationsCounter: 0, updateConversationsCounter: 0,
textareaAutoHeight: undefined,
MAX_MESSAGE_LENGTH: MAX_MESSAGE_LENGTH.toString(), MAX_MESSAGE_LENGTH: MAX_MESSAGE_LENGTH.toString(),
}; };
}, },
async mounted () { async mounted () {
this.$root.$on('pm::refresh', async () => { // notification click to refresh
this.$root.$on(EVENTS.PM_REFRESH, async () => {
await this.reload(); await this.reload();
this.selectConversation(this.loadedConversations[0].uuid, true); this.selectFirstConversation();
});
// header sync button
this.$root.$on(EVENTS.RESYNC_COMPLETED, async () => {
await this.reload();
this.selectFirstConversation();
}); });
await this.reload(); await this.reload();
@@ -594,7 +648,7 @@ export default {
} }
}, },
destroyed () { destroyed () {
this.$root.$off('habitica::new-private-message'); this.$root.$off(EVENTS.RESYNC_COMPLETED);
}, },
computed: { computed: {
...mapState({ user: 'user.data' }), ...mapState({ user: 'user.data' }),
@@ -616,6 +670,7 @@ export default {
id: '', id: '',
text: '', text: '',
timestamp: new Date(), timestamp: new Date(),
canReceive: true,
}]; }];
} }
// Create conversation objects // Create conversation objects
@@ -635,6 +690,7 @@ export default {
contributor: recentMessage.contributor, contributor: recentMessage.contributor,
userStyles: recentMessage.userStyles, userStyles: recentMessage.userStyles,
backer: recentMessage.backer, backer: recentMessage.backer,
canReceive: recentMessage.canReceive,
canLoadMore: false, canLoadMore: false,
page: 0, page: 0,
}; };
@@ -693,6 +749,39 @@ export default {
description: this.$t('PMPlaceholderDescription'), description: this.$t('PMPlaceholderDescription'),
}; };
}, },
disabledTexts () {
if (this.user.flags.chatRevoked) {
return {
title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('chatPrivilegesRevoked'),
};
}
if (this.user.inbox.optOut) {
return {
title: this.$t('PMDisabledCaptionTitle'),
description: this.$t('PMDisabledCaptionText'),
};
}
if (this.selectedConversation && this.selectedConversation.key) {
if (this.user.inbox.blocks.includes(this.selectedConversation.key)) {
return {
title: this.$t('PMDisabledCaptionTitle'),
description: this.$t('PMUnblockUserToSendMessages'),
};
}
if (!this.selectedConversation.canReceive) {
return {
title: this.$t('PMCanNotReply'),
description: this.$t('PMUserDoesNotReceiveMessages'),
};
}
}
return null;
},
optTextSet () { optTextSet () {
if (!this.user.inbox.optOut) { if (!this.user.inbox.optOut) {
return { return {
@@ -711,6 +800,10 @@ export default {
} }
return ''; return '';
}, },
newMessageDisabled () {
return !this.selectedConversation || !this.selectedConversation.key
|| this.disabledTexts !== null;
},
}, },
methods: { methods: {
@@ -721,6 +814,8 @@ export default {
this.loadedConversations = conversationRes.data.data; this.loadedConversations = conversationRes.data.data;
this.selectedConversation = {}; this.selectedConversation = {};
await this.$store.dispatch('user:markPrivMessagesRead');
this.loaded = true; this.loaded = true;
}, },
messageRemoved (message) { messageRemoved (message) {
@@ -813,6 +908,7 @@ export default {
}); });
this.newMessage = ''; this.newMessage = '';
this.autoSize();
}, },
removeTags (html) { removeTags (html) {
const tmp = document.createElement('DIV'); const tmp = document.createElement('DIV');
@@ -856,6 +952,27 @@ export default {
this.selectedConversation.canLoadMore = loadedMessages.length === 10; this.selectedConversation.canLoadMore = loadedMessages.length === 10;
this.messagesLoading = false; this.messagesLoading = false;
}, },
autoSize () {
const { textarea } = this.$refs;
// weird issue: browser only removing the scrollHeight / clientHeight per key event - 56-54-52
let { scrollHeight } = textarea;
if (this.newMessage === '') {
// reset height when the message was removed again
scrollHeight = 40;
}
if (scrollHeight > MAX_TEXTAREA_HEIGHT) {
scrollHeight = MAX_TEXTAREA_HEIGHT;
}
this.textareaAutoHeight = `${scrollHeight}px`;
},
selectFirstConversation () {
if (this.loadedConversations.length > 0) {
this.selectConversation(this.loadedConversations[0].uuid, true);
}
},
}, },
}; };
</script> </script>

View File

@@ -6,6 +6,7 @@ import { togglePinnedItem as togglePinnedItemOp } from '@/../../common/script/op
import changeClassOp from '@/../../common/script/ops/changeClass'; import changeClassOp from '@/../../common/script/ops/changeClass';
import disableClassesOp from '@/../../common/script/ops/disableClasses'; import disableClassesOp from '@/../../common/script/ops/disableClasses';
import openMysteryItemOp from '@/../../common/script/ops/openMysteryItem'; import openMysteryItemOp from '@/../../common/script/ops/openMysteryItem';
import markPMSRead from '../../../../common/script/ops/markPMSRead';
export function fetch (store, options = {}) { // eslint-disable-line no-shadow export function fetch (store, options = {}) { // eslint-disable-line no-shadow
return loadAsyncResource({ return loadAsyncResource({
@@ -172,6 +173,11 @@ export function unblock (store, params) {
return axios.post(`/api/v4/user/block/${params.uuid}`); return axios.post(`/api/v4/user/block/${params.uuid}`);
} }
export function markPrivMessagesRead (store) {
markPMSRead(store.state.user.data);
return axios.post('/api/v4/user/mark-pms-read');
}
export function newPrivateMessageTo (store, params) { export function newPrivateMessageTo (store, params) {
const { member } = params; const { member } = params;

View File

@@ -296,7 +296,7 @@
"dismissAll": "Dismiss All", "dismissAll": "Dismiss All",
"messages": "Messages", "messages": "Messages",
"emptyMessagesLine1": "You don't have any messages", "emptyMessagesLine1": "You don't have any messages",
"emptyMessagesLine2": "Send a message to start a conversation!", "emptyMessagesLine2": "You can send a new message to a user by visiting their profile and clicking the \"Message\" button.",
"userSentMessage": "<span class=\"notification-bold\"><%= user %></span> sent you a message", "userSentMessage": "<span class=\"notification-bold\"><%= user %></span> sent you a message",
"letsgo": "Let's Go!", "letsgo": "Let's Go!",
"selected": "Selected", "selected": "Selected",

View File

@@ -143,6 +143,9 @@
"PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.", "PMDisabledOptPopoverText": "Private Messages are disabled. Enable this option to allow users to contact you via your profile.",
"PMDisabledCaptionTitle": "Private Messages are disabled", "PMDisabledCaptionTitle": "Private Messages are disabled",
"PMDisabledCaptionText": "You can still send messages, but no one can send them to you.", "PMDisabledCaptionText": "You can still send messages, but no one can send them to you.",
"PMCanNotReply": "You can not reply to this conversation",
"PMUserDoesNotReceiveMessages": "This user is no longer receiving private messages",
"PMUnblockUserToSendMessages": "Unblock this user to continue sending and receiving messages.",
"block": "Block", "block": "Block",
"unblock": "Un-block", "unblock": "Un-block",
"blockWarning": "Block - This will have no effect if the player is a moderator now or becomes a moderator in future.", "blockWarning": "Block - This will have no effect if the player is a moderator now or becomes a moderator in future.",

View File

@@ -93,6 +93,7 @@ api.clearMessages = {
* "text":"last message of conversation", * "text":"last message of conversation",
* "userStyles": {}, * "userStyles": {},
* "contributor": {}, * "contributor": {},
* "canReceive": true,
* "count":1 * "count":1
* } * }
* } * }

View File

@@ -2,70 +2,40 @@ import { inboxModel as Inbox, setUserStyles } from '../../models/message';
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
/** /**
* Get the users for conversations * Get the current user (avatar/setting etc) 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 * @param users
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function usersMapByConversations (owner, users) { async function usersMapByConversations (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 = {}; const usersMap = {};
for (const usr of usersAr) { const usersQuery = {
usersMap[usr._id] = usr; _id: { $in: users },
} };
// if a conversation doesn't have a response of the chat-partner, const loadedUsers = await User.find(usersQuery, {
// those won't be listed by the query above _id: 1,
const usersStillNeedToBeLoaded = users.filter(userId => !usersMap[userId]); contributor: 1,
backer: 1,
items: 1,
preferences: 1,
stats: 1,
flags: 1,
inbox: 1,
}).exec();
if (usersStillNeedToBeLoaded.length > 0) { for (const usr of loadedUsers) {
const usersQuery = { const loadedUserConversation = {
_id: { $in: usersStillNeedToBeLoaded }, _id: usr._id,
backer: usr.backer,
contributor: usr.contributor,
optOut: usr.inbox.optOut,
blocks: usr.inbox.blocks || [],
}; };
// map user values to conversation properties
setUserStyles(loadedUserConversation, usr);
const loadedUsers = await User.find(usersQuery, { usersMap[usr._id] = loadedUserConversation;
_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; return usersMap;
@@ -98,7 +68,7 @@ export async function listConversations (owner) {
const userIdList = conversationsList.map(c => c._id); const userIdList = conversationsList.map(c => c._id);
// get user-info based on conversations // get user-info based on conversations
const usersMap = await usersMapByConversations(owner, userIdList); const usersMap = await usersMapByConversations(userIdList);
const conversations = conversationsList.map(res => { const conversations = conversationsList.map(res => {
const uuid = res._id; const uuid = res._id;
@@ -109,9 +79,15 @@ export async function listConversations (owner) {
}; };
if (usersMap[uuid]) { if (usersMap[uuid]) {
conversation.userStyles = usersMap[uuid].userStyles; const user = usersMap[uuid];
conversation.contributor = usersMap[uuid].contributor;
conversation.backer = usersMap[uuid].backer; conversation.userStyles = user.userStyles;
conversation.contributor = user.contributor;
conversation.backer = user.backer;
const isOwnerBlocked = user.blocks.includes(owner._id);
conversation.canReceive = !(user.optOut || isOwnerBlocked) || owner.isAdmin();
} }
return conversation; return conversation;