mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 22:27:26 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
6
website/client/src/libs/events.js
Normal file
6
website/client/src/libs/events.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const EVENTS = {
|
||||
RESYNC_REQUESTED: 'habitica::resync-requested',
|
||||
RESYNC_COMPLETED: 'habitica::resync-completed',
|
||||
|
||||
PM_REFRESH: 'pm::refresh',
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -93,6 +93,7 @@ api.clearMessages = {
|
||||
* "text":"last message of conversation",
|
||||
* "userStyles": {},
|
||||
* "contributor": {},
|
||||
* "canReceive": true,
|
||||
* "count":1
|
||||
* }
|
||||
* }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user