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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { storiesOf } from '@storybook/vue';
import { text, withKnobs } from '@storybook/addon-knobs';
const stories = storiesOf('Textare', module);
const stories = storiesOf('Textarea', module);
stories.addDecorator(withKnobs);
@@ -11,7 +11,11 @@ stories
components: { },
template: `
<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 />
<textarea disabled>Disabled {{text}}</textarea><br />

View File

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

View File

@@ -6,6 +6,7 @@ import { togglePinnedItem as togglePinnedItemOp } from '@/../../common/script/op
import changeClassOp from '@/../../common/script/ops/changeClass';
import disableClassesOp from '@/../../common/script/ops/disableClasses';
import openMysteryItemOp from '@/../../common/script/ops/openMysteryItem';
import markPMSRead from '../../../../common/script/ops/markPMSRead';
export function fetch (store, options = {}) { // eslint-disable-line no-shadow
return loadAsyncResource({
@@ -172,6 +173,11 @@ export function unblock (store, params) {
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) {
const { member } = params;

View File

@@ -296,7 +296,7 @@
"dismissAll": "Dismiss All",
"messages": "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",
"letsgo": "Let's Go!",
"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.",
"PMDisabledCaptionTitle": "Private Messages are disabled",
"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",
"unblock": "Un-block",
"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",
* "userStyles": {},
* "contributor": {},
* "canReceive": true,
* "count":1
* }
* }

View File

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