combined messages restyling - next round (#15386)

* split component prepare new views / states

* extract empty and disabled state as components

* fix empty state mail icon

* first logic switching between modes, move page to /private-messages/index.vue

* extract autoCompleteHelper.js

* style header + start new message input

* style plus button + focus input

* state logic, types for sanity

* WIP PM new Message started

* add /members/username test

* first design changes to messageCard

* delete private message or chat - based on the mode

* copy as todo

* mention links to modal

* report chat or private message

* WIP likeButton

* likeButton styling

* hide like on private message cards

* fix unit test

* replace copy as todo - to just a copy to clipboard

* style changes

* menu position + like button width

* dropdown items background + like font

* fix like button padding

* move api endpoints and tests around to group inbox methods  + like for inbox private messages

* restyle system messages

* Dropdown Radius and Padding

* WIP system messages

* fix lint

* copy delta commit of allowing liking own private messages

* enable liking private messages

* fix menu non hovered item icon color

* fix import path

* ignore background on system messages

* requested changes + migration

* update migration to update the unique id to some messages and delete the duplicates

* migration based on users pagination

* fix(migration): use Promise.all

* change to bulkWrites per User, and all messages in one run (of a user)

* check for array

* use rest operator ...

* skip sorting to get the users

* remove migration, disable like for private messages without uniqueMessageId

* lean+bulkWrite for likes, add time checks for like and auth for further debugging

* add a limit 2 get the messages by uniqueId

* Adding a simple server start script

* remove pinned nodemon dep

* fix inbox controller/tests

* fix / requested style changes

* fix empty state padding /

* hide avatar weapons on messages - fix avatar spacing on messages

* Hourglass Simplification (#15323)

* begin removing obsolete tests

* begin refactoring

* update cron tests

* cleanup

* finish basic implementation of new logic

* add more subscription tests

* subscription test improvements

* return nextHourglassDate again

* fix gem limit

* fix(test): short circuit this.

* fix(admin): correct logic and style for shrimple subs

* WIP(frontend): draft of main subs page view

* fix hourglass count

* Fix hourglass logic for upgrades

* fix admin panel display

* WIP(subs): extant Stripe state

* fix admin panel strings

* fix missing transaction type

* add new field for cumulative subscription count

* show date for hourglass bonus if it was received

* fix test

* feat(subscription): max Gems progress readout

* fix(css): correct and refactor heights and selection states

* fix(subs): correct border-radius and redirect

* fix(stripe): correct redirect after success

* Admin panel display fixes

* don’t give additional HG for new sub if they already got one this month

* fix issue with promo hourglasses

* fix(subscription): update layout when gifting

* fix(subscriptions): more gift layout revisions

* fix(subscriptions): minor visual updates

* fix(subs): pass autoRenews through Stripe

* fix(subs): gifts DON't renew

* fix(lint): unnecessary ternary

* fix(lint): do negate object ig

* fix(subs): try again on gifts

* fix(subs): unhovery and un-12-monthy

* fix bug with incorrectly giving HG bonus

* remove only

* fix test

* fix test

* fix(subs): also redirect to subs after gift sub

* fix(subs): fix typeError

* fix(g1g1): don't try to find Gems promo during bogo

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Kalista Payne <sabe@habitica.com>

* chore(sprites): update subproject

* fix(layout): tighten cancellation note

* fix(subs): Google wording and HG escape

* chore(testing): fake g1g1 dates

* fix(subs): don't hide HG preview entirely

* fix(subs): center next hourglass message

* working validatedTextInput.vue within start-new-conversation-input-header.vue 🎉

* fix(git): remove changes from old develop

* Revert "fix(git): remove changes from old develop"

This reverts commit 0e30f7df00.

* fix(git): no actually just this file i guesss

* adding an empty loading state, hiding

* fought the avatar arch nemesis again

* fix chatMessages (party chat) message spacing

* move disabled text back to above the input area - re-enable input area

* show disabled private messages top panel

* fix font color

* fixing uiStates - removing disabled - moving the own user check to the last

* fix(lint): add missing prop defaults

* fix(lint): object default should be fn

* fix(chat): correct grammar in error

* remove weapon position relative

* revert most of avatar.vue changes, add back weapons in chat message UI

* show date tooltip above system / skill messages

* fix toggle disable icon position

* trivial CSS cleanup

* fix(typo): English syntax in test

* chore(test): small style cleanup

* chore(logging): revert debug function

* chore(debug): remove timers from inbox like

---------

Co-authored-by: SabreCat <sabe@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
Co-authored-by: Phillip Thelen <phillip@habitica.com>
This commit is contained in:
negue
2025-03-05 00:00:24 +01:00
committed by GitHub
parent 95142e3684
commit 7c9c45ac5f
45 changed files with 2351 additions and 1358 deletions

2
.gitignore vendored
View File

@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev # mongodb replica set for local dev
mongodb-*.tgz mongodb-*.tgz
/mongodb-data /mongodb-data*
/.nyc_output /.nyc_output

View File

@@ -110,6 +110,7 @@
"start:simple": "node ./website/server/index.js", "start:simple": "node ./website/server/index.js",
"debug": "gulp nodemon --inspect", "debug": "gulp nodemon --inspect",
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet", "mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install", "postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc", "apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh" "heroku-postbuild": ".heroku/report_deploy.sh"

View File

@@ -0,0 +1,56 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import common from '../../../../../website/common';
describe('GET /members/username/:username', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.username', async () => {
await expect(user.get('/members/username/')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns a member\'s public data only', async () => {
// make sure user has all the fields that can be returned by the getMember call
const member = await generateUser({
contributor: { level: 1 },
backer: { tier: 3 },
preferences: {
costume: false,
background: 'volcano',
},
secret: {
text: 'Clark Kent',
},
});
const memberRes = await user.get(`/members/username/${member.auth.local.username}`);
expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']);
expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
].sort());
expect(memberRes.stats.maxMP).to.exist;
expect(memberRes.stats.maxHealth).to.equal(common.maxHealth);
expect(memberRes.stats.toNextLevel).to.equal(common.tnl(memberRes.stats.lvl));
expect(memberRes.inbox.optOut).to.exist;
expect(memberRes.inbox.canReceive).to.exist;
expect(memberRes.inbox.messages).to.not.exist;
expect(memberRes.secret).to.not.exist;
expect(memberRes.blocks).to.not.exist;
});
});

View File

@@ -0,0 +1,104 @@
import find from 'lodash/find';
import {
generateUser,
translate as t,
} from '../../../helpers/api-integration/v4';
/**
* Checks the messages array if the uniqueMessageId has the like flag
* @param {InboxMessage[]} messages
* @param {String} uniqueMessageId
* @param {String} userId
* @param {Boolean} likeStatus
*/
function expectMessagesLikeStatus (messages, uniqueMessageId, userId, likeStatus) {
const messageToCheck = find(messages, { uniqueMessageId });
expect(messageToCheck.likes[userId]).to.equal(likeStatus);
}
// eslint-disable-next-line mocha/no-exclusive-tests
describe('POST /inbox/like-private-message/:messageId', () => {
let userToSendMessage;
const getLikeUrl = messageId => `/inbox/like-private-message/${messageId}`;
before(async () => {
userToSendMessage = await generateUser();
});
it('returns an error when private message is not found', async () => {
await expect(userToSendMessage.post(getLikeUrl('some-unknown-id')))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageGroupChatNotFound'),
});
});
it('likes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const senderMessages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(senderMessages, uniqueMessageId, receiver._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true);
});
it('allows a user to like their own private message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await userToSendMessage.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[userToSendMessage._id]).to.equal(true);
const messages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(messages, uniqueMessageId, userToSendMessage._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true);
});
it('unlikes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const unlikeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(unlikeResult.likes[receiver._id]).to.equal(false);
const messages = await userToSendMessage.get('/inbox/messages');
const messageToCheck = find(messages, { id: sentMessageResult.message.id });
expect(messageToCheck.likes[receiver._id]).to.equal(false);
});
});

View File

@@ -7,7 +7,7 @@ module.exports = {
extends: [ extends: [
'habitrpg/lib/vue', 'habitrpg/lib/vue',
], ],
ignorePatterns: ['dist/', 'node_modules/'], ignorePatterns: ['dist/', 'node_modules/', '*.d.ts'],
rules: { rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',

View File

@@ -101,8 +101,7 @@
.btn-secondary, .btn-secondary,
.dropdown > .btn-secondary.dropdown-toggle:not(.btn-success), .dropdown > .btn-secondary.dropdown-toggle:not(.btn-success),
.show > .btn-secondary.dropdown-toggle:not(.btn-success) .show > .btn-secondary.dropdown-toggle:not(.btn-success) {
{
background: $white; background: $white;
border: 2px solid transparent; border: 2px solid transparent;
color: $gray-50; color: $gray-50;
@@ -298,6 +297,16 @@
box-shadow: none; box-shadow: none;
} }
.btn-flat,
.dropdown > .btn-flat.dropdown-toggle:not(.btn-success),
.show > .btn-flat.dropdown-toggle:not(.btn-success) {
&.with-icon {
.svg-icon.color {
color: var(--icon-color);
}
}
}
.btn-cancel { .btn-cancel {
color: $blue-10; color: $blue-10;
} }

View File

@@ -38,7 +38,12 @@
border-radius: 2px; border-radius: 2px;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24); box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
padding: 0; padding: 0;
}
.no-min-width {
.dropdown-menu {
min-width: 0 !important;
}
} }
// shared dropdown-item styles // shared dropdown-item styles
@@ -54,6 +59,8 @@
color: $gray-50 !important; color: $gray-50 !important;
cursor: pointer; cursor: pointer;
--dropdown-item-hover-icon-color: #{$gray-200};
&:focus { &:focus {
outline: none; outline: none;
background-color: inherit; background-color: inherit;
@@ -88,7 +95,7 @@
&:not(:hover) { &:not(:hover) {
.with-icon .svg-icon { .with-icon .svg-icon {
color: $gray-200; color: var(dropdown-item-hover-icon-color);
} }
} }
} }
@@ -151,7 +158,7 @@
// selectList.vue items sizing // selectList.vue items sizing
.selectListItem .dropdown-item { .selectListItem .dropdown-item {
padding: 0.25rem 0.75rem; padding: 0.25rem 1rem 0.25rem 0.75rem;
height: 32px; height: 32px;
&:active, &:hover, &:focus, &.active { &:active, &:hover, &:focus, &.active {

View File

@@ -3,7 +3,7 @@
v-if="member.preferences" v-if="member.preferences"
class="avatar" class="avatar"
:style="{width, height, paddingTop}" :style="{width, height, paddingTop}"
:class="backgroundClass" :class="topLevelClassList"
@click.prevent="castEnd()" @click.prevent="castEnd()"
> >
<div <div
@@ -55,7 +55,11 @@
<span :class="[getGearClass('eyewear'), specialMountClass]"></span> <span :class="[getGearClass('eyewear'), specialMountClass]"></span>
<span :class="[getGearClass('head'), specialMountClass]"></span> <span :class="[getGearClass('head'), specialMountClass]"></span>
<span :class="[getGearClass('headAccessory'), specialMountClass]"></span> <span :class="[getGearClass('headAccessory'), specialMountClass]"></span>
<span :class="['hair_flower_' + member.preferences.hair.flower, specialMountClass]"></span> <span
:class="[
'hair_flower_' + member.preferences.hair.flower, specialMountClass
]"
></span>
<span <span
v-if="!hideGear('shield')" v-if="!hideGear('shield')"
:class="[getGearClass('shield'), specialMountClass]" :class="[getGearClass('shield'), specialMountClass]"
@@ -63,6 +67,7 @@
<span <span
v-if="!hideGear('weapon')" v-if="!hideGear('weapon')"
:class="[getGearClass('weapon'), specialMountClass]" :class="[getGearClass('weapon'), specialMountClass]"
class="weapon"
></span> ></span>
</template> </template>
<!-- Resting--> <!-- Resting-->
@@ -96,15 +101,23 @@
.avatar { .avatar {
width: 141px; width: 141px;
height: 147px;
image-rendering: pixelated; image-rendering: pixelated;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
&.centered-avatar {
margin: 0 auto;
}
// resetting the additional padding
margin-bottom: -0.5rem !important;
} }
.character-sprites { .character-sprites {
width: 90px; width: 90px;
height: 90px; height: 90px;
display: inline-flex;
} }
.character-sprites span { .character-sprites span {
@@ -123,6 +136,22 @@
.invert { .invert {
filter: invert(100%); filter: invert(100%);
} }
.debug {
border: 1px solid red;
.character-sprites {
border: 1px solid blue;
}
.weapon {
border: 1px solid green;
}
span {
border: 1px solid yellow;
}
}
</style> </style>
<script> <script>
@@ -133,12 +162,24 @@ import foolPet from '../mixins/foolPet';
import ClassBadge from '@/components/members/classBadge'; import ClassBadge from '@/components/members/classBadge';
/**
* TODO replace avatarOnly with multiple options like
* - showMount
* - showPet
* - showBackground
* - showWeapons
*/
export default { export default {
components: { components: {
ClassBadge, ClassBadge,
}, },
mixins: [foolPet], mixins: [foolPet],
props: { props: {
debugMode: {
type: Boolean,
default: false,
},
member: { member: {
type: Object, type: Object,
required: true, required: true,
@@ -156,14 +197,21 @@ export default {
}, },
overrideAvatarGear: { overrideAvatarGear: {
type: Object, type: Object,
default (data) {
return data;
},
}, },
width: { width: {
type: Number, type: String,
default: 140, default: '140px',
}, },
height: { height: {
type: Number, type: String,
default: 147, default: undefined,
},
centerAvatar: {
type: Boolean,
default: false,
}, },
spritesMargin: { spritesMargin: {
type: String, type: String,
@@ -171,11 +219,16 @@ export default {
}, },
overrideTopPadding: { overrideTopPadding: {
type: String, type: String,
default: null,
}, },
showVisualBuffs: { showVisualBuffs: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showWeapon: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
...mapState({ ...mapState({
@@ -204,6 +257,19 @@ export default {
return val; return val;
}, },
topLevelClassList () {
const classes = [this.backgroundClass];
if (this.debugMode) {
classes.push('debug');
}
if (this.centerAvatar) {
classes.push('centered-avatar');
}
return classes.join(' ');
},
backgroundClass () { backgroundClass () {
if (this.member) { if (this.member) {
const { background } = this.member.preferences; const { background } = this.member.preferences;
@@ -290,6 +356,10 @@ export default {
}, },
hideGear (gearType) { hideGear (gearType) {
if (!this.member) return true; if (!this.member) return true;
if (!this.showWeapon) {
return true;
}
if (gearType === 'weapon') { if (gearType === 'weapon') {
const equippedWeapon = this.member.items.gear[this.costumeClass][gearType]; const equippedWeapon = this.member.items.gear[this.costumeClass][gearType];

View File

@@ -1,352 +0,0 @@
<template>
<div>
<div
v-if="isUserMentioned"
class="mentioned-icon"
></div>
<div
v-if="hasPermission(user, 'moderator') && msg.flagCount"
class="message-hidden"
>
{{ flagCountDescription }}
</div>
<div class="card-body">
<user-link
:user-id="msg.uuid"
:name="msg.user"
:backer="msg.backer"
:contributor="msg.contributor"
/>
<p class="time">
<span
v-if="msg.username"
class="mr-1"
>@{{ msg.username }}</span>
<span
v-if="msg.username"
class="mr-1"
></span>
<span
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p>
<div
ref="markdownContainer"
class="text markdown"
dir="auto"
v-html="parseMarkdown(msg.text)"
></div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
class="action d-flex align-items-center"
@click="copyAsTodo(msg)"
>
<div
class="svg-icon"
v-html="icons.copy"
></div>
<div>{{ $t('copyAsTodo') }}</div>
</div>
<div
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
&& (!isMessageReported || hasPermission(user, 'moderator'))"
class="action d-flex align-items-center"
@click="report(msg)"
>
<div
v-once
class="svg-icon"
v-html="icons.report"
></div>
<div v-once>
{{ $t('report') }}
</div>
</div>
<div
v-if="msg.uuid === user._id || hasPermission(user, 'moderator')"
class="action d-flex align-items-center"
@click="remove()"
>
<div
v-once
class="svg-icon"
v-html="icons.delete"
></div>
<div v-once>
{{ $t('delete') }}
</div>
</div>
<div
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
class="ml-auto d-flex"
>
<div
v-if="likeCount > 0"
class="action d-flex align-items-center mr-0"
:class="{activeLike: msg.likes[user._id]}"
@click="like()"
>
<div
class="svg-icon"
:title="$t('liked')"
v-html="icons.liked"
></div>
+{{ likeCount }}
</div>
<div
v-if="likeCount === 0"
class="action d-flex align-items-center mr-0"
:class="{activeLike: msg.likes[user._id]}"
@click="like()"
>
<div
class="svg-icon"
:title="$t('like')"
v-html="icons.like"
></div>
</div>
</div>
<span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
</div>
</div>
</div>
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem;
}
.at-text {
color: #6133b4;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.mentioned-icon {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #bda8ff;
box-shadow: 0 1px 1px 0 rgba(26, 24, 29, 0.12);
position: absolute;
right: -.5em;
top: -.5em;
}
.message-hidden {
margin-left: 1.5em;
margin-top: 1em;
color: red;
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.time {
font-size: 12px;
color: #878190;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: #4e4a57;
text-align: initial;
min-height: 0rem;
}
}
.action {
display: inline-block;
color: #878190;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: #A5A1AC;
margin-right: .2em;
width: 16px;
}
}
.activeLike {
color: $purple-300;
.svg-icon {
color: $purple-400;
}
}
</style>
<script>
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import { CHAT_FLAG_LIMIT_FOR_HIDING, CHAT_FLAG_FROM_SHADOW_MUTE } from '@/../../common/script/constants';
import renderWithMentions from '@/libs/renderWithMentions';
import { userStateMixin } from '../../mixins/userState';
import userLink from '../userLink';
import deleteIcon from '@/assets/svg/delete.svg';
import copyIcon from '@/assets/svg/copy.svg';
import likeIcon from '@/assets/svg/like.svg';
import likedIcon from '@/assets/svg/liked.svg';
import reportIcon from '@/assets/svg/report.svg';
export default {
components: { userLink },
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
date (value) {
// @TODO: Vue doesn't support this so we cant user preference
return moment(value).toDate().toString();
},
},
mixins: [userStateMixin],
props: {
msg: {},
groupId: {},
},
data () {
return {
icons: Object.freeze({
like: likeIcon,
copy: copyIcon,
report: reportIcon,
delete: deleteIcon,
liked: likedIcon,
}),
reported: false,
};
},
computed: {
isUserMentioned () {
const message = this.msg;
if (message.highlight) return true;
const { user } = this;
const displayName = user.profile.name;
const { username } = user.auth.local;
const pattern = `@(${escapeRegExp(displayName)}|${escapeRegExp(username)})(\\b)`;
message.highlight = new RegExp(pattern, 'i').test(message.text);
return message.highlight;
},
likeCount () {
const message = this.msg;
if (!message.likes) return 0;
let likeCount = 0;
for (const key of Object.keys(message.likes)) {
const like = message.likes[key];
if (like) likeCount += 1;
}
return likeCount;
},
isMessageReported () {
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
},
flagCountDescription () {
if (!this.msg.flagCount) return '';
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) return 'Message flagged once, not hidden';
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)';
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
for (let i = 0; i < links.length; i += 1) {
let link = links[i].pathname;
// Internet Explorer does not provide the leading slash character in the pathname
link = link.charAt(0) === '/' ? link : `/${link}`;
if (link.startsWith('/profile/')) {
links[i].onclick = ev => {
ev.preventDefault();
this.$router.push({ path: link });
};
}
}
this.CHAT_FLAG_LIMIT_FOR_HIDING = CHAT_FLAG_LIMIT_FOR_HIDING;
this.CHAT_FLAG_FROM_SHADOW_MUTE = CHAT_FLAG_FROM_SHADOW_MUTE;
this.$emit('chat-card-mounted', this.msg.id);
},
methods: {
async like () {
const message = cloneDeep(this.msg);
await this.$store.dispatch('chat:like', {
groupId: this.groupId,
chatId: message.id,
});
message.likes[this.user._id] = !message.likes[this.user._id];
this.$emit('message-liked', message);
this.$root.$emit('bv::hide::tooltip');
},
likeTooltip (likedStatus) {
if (!likedStatus) return this.$t('like');
return null;
},
copyAsTodo (message) {
this.$root.$emit('habitica::copy-as-todo', message);
},
report () {
this.$root.$on('habitica:report-result', data => {
if (data.ok) {
this.reported = true;
}
this.$root.$off('habitica:report-result');
});
this.$root.$emit('habitica::report-chat', {
message: this.msg,
groupId: this.groupId || 'privateMessage',
});
},
async remove () {
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return; // eslint-disable-line no-alert
const message = this.msg;
this.$emit('message-removed', message);
await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId,
chatId: message.id,
});
},
parseMarkdown (text) {
return renderWithMentions(text, this.user);
},
},
};
</script>

View File

@@ -3,15 +3,6 @@
ref="container" ref="container"
class="container-fluid" class="container-fluid"
> >
<div class="row">
<div class="col-12">
<copy-as-todo-modal
:group-type="groupType"
:group-name="groupName"
:group-id="groupId"
/>
</div>
</div>
<div class="row loadmore"> <div class="row loadmore">
<div v-if="canLoadMore"> <div v-if="canLoadMore">
<div class="loadmore-divider"></div> <div class="loadmore-divider"></div>
@@ -33,6 +24,8 @@
<div <div
v-for="msg in messages.filter(m => chat && canViewFlag(m))" v-for="msg in messages.filter(m => chat && canViewFlag(m))"
:key="msg.id" :key="msg.id"
class="message-row"
:class="{ 'margin-right': user._id !== msg.uuid}"
> >
<div class="d-flex"> <div class="d-flex">
<avatar <avatar
@@ -45,16 +38,14 @@
:override-top-padding="'14px'" :override-top-padding="'14px'"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
<div class="card"> <message-card
<chat-card
:msg="msg" :msg="msg"
:group-id="groupId" :group-id="groupId"
:user-sent-message="user._id === msg.uuid"
@message-liked="messageLiked" @message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@show-member-modal="showMemberModal" @message-card-mounted="itemWasMounted"
@chat-card-mounted="itemWasMounted"
/> />
</div>
<avatar <avatar
v-if="user._id === msg.uuid" v-if="user._id === msg.uuid"
:class="{ invisible: avatarUnavailable(msg) }" :class="{ invisible: avatarUnavailable(msg) }"
@@ -105,11 +96,6 @@
} }
} }
.avatar-left {
margin-left: -1.5rem;
margin-right: 2rem;
}
.hr { .hr {
width: 100%; width: 100%;
height: 20px; height: 20px;
@@ -137,11 +123,27 @@
margin-bottom: .5em; margin-bottom: .5em;
padding: 0rem; padding: 0rem;
width: 90%; width: 90%;
&.system-message {
width: 100%;
}
} }
.message-scroll .d-flex { .message-scroll .d-flex {
min-width: 1px; min-width: 1px;
} }
.message-row {
margin-left: 12px;
margin-right: 0;
margin-bottom: 1.2rem;
&:not(.margin-right) {
.d-flex {
justify-content: flex-end;
}
}
}
</style> </style>
<script> <script>
@@ -152,13 +154,13 @@ import findIndex from 'lodash/findIndex';
import { userStateMixin } from '../../mixins/userState'; import { userStateMixin } from '../../mixins/userState';
import Avatar from '../avatar'; import Avatar from '../avatar';
import copyAsTodoModal from './copyAsTodoModal'; import MessageCard from '@/components/messages/messageCard.vue';
import chatCard from './chatCard';
// TODO merge chatMessages.vue (party message list) with messageList.vue (private message list)
export default { export default {
components: { components: {
copyAsTodoModal, MessageCard,
chatCard,
Avatar, Avatar,
}, },
mixins: [userStateMixin], mixins: [userStateMixin],

View File

@@ -1,105 +0,0 @@
<template>
<b-modal
id="copyAsTodo"
:title="$t('copyMessageAsToDo')"
:hide-footer="true"
size="md"
>
<div class="form-group">
<input
v-model="task.text"
class="form-control"
type="text"
>
</div>
<div class="form-group">
<textarea
v-model="task.notes"
class="form-control"
rows="5"
focus-element="true"
></textarea>
</div>
<hr>
<task
v-if="task._id"
:is-user="isUser"
:task="task"
/>
<div class="modal-footer">
<button
class="btn btn-secondary"
@click="close()"
>
{{ $t('close') }}
</button>
<button
class="btn btn-primary"
@click="saveTodo()"
>
{{ $t('submit') }}
</button>
</div>
</b-modal>
</template>
<script>
import taskDefaults from '@/../../common/script/libs/taskDefaults';
import { mapActions } from '@/libs/store';
import markdownDirective from '@/directives/markdown';
import notificationsMixin from '@/mixins/notifications';
import Task from '@/components/tasks/task';
const baseUrl = 'https://habitica.com';
export default {
directives: {
markdown: markdownDirective,
},
components: {
Task,
},
mixins: [notificationsMixin],
props: ['copyingMessage', 'groupType', 'groupName', 'groupId'],
data () {
return {
isUser: true,
task: {},
};
},
mounted () {
this.$root.$on('habitica::copy-as-todo', message => {
const notes = `${message.user || 'system message'}${message.user ? ' wrote' : ''} in [${this.groupName}](${this.groupPath()})`;
const newTask = {
text: message.text,
type: 'todo',
notes,
};
this.task = taskDefaults(newTask, this.$store.state.user.data);
this.$root.$emit('bv::show::modal', 'copyAsTodo');
});
},
beforeDestroy () {
this.$root.$off('habitica::copy-as-todo');
},
methods: {
...mapActions({
createTask: 'tasks:create',
}),
groupPath () {
if (this.groupType === 'party') {
return `${baseUrl}/party`;
}
return `${baseUrl}/groups/guild/${this.groupId}`;
},
close () {
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
},
saveTodo () {
this.createTask(this.task);
this.text(this.$t('messageAddedAsToDo'));
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
},
},
};
</script>

View File

@@ -22,13 +22,13 @@
:placeholder="placeholder" :placeholder="placeholder"
:class="{'user-entry': newMessage}" :class="{'user-entry': newMessage}"
:maxlength="MAX_MESSAGE_LENGTH" :maxlength="MAX_MESSAGE_LENGTH"
@keydown="updateCarretPosition" @keydown="autoCompleteMixinUpdateCarretPosition"
@keyup.ctrl.enter="sendMessageShortcut()" @keyup.ctrl.enter="sendMessageShortcut()"
@keydown.tab="handleTab($event)" @keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="selectPreviousAutocomplete($event)" @keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="selectNextAutocomplete($event)" @keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="selectAutocomplete($event)" @keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="handleEscape($event)" @keydown.esc="autoCompleteMixinHandleEscape($event)"
@paste="disableMessageSendShortcut()" @paste="disableMessageSendShortcut()"
></textarea> ></textarea>
<span>{{ currentLength }} / {{ MAX_MESSAGE_LENGTH }}</span> <span>{{ currentLength }} / {{ MAX_MESSAGE_LENGTH }}</span>
@@ -36,8 +36,8 @@
ref="autocomplete" ref="autocomplete"
:text="newMessage" :text="newMessage"
:textbox="textbox" :textbox="textbox"
:coords="coords" :coords="mixinData.autoComplete.coords"
:caret-position="caretPosition" :caret-position="mixinData.autoComplete.caretPosition"
:chat="group.chat" :chat="group.chat"
@select="selectedAutocomplete" @select="selectedAutocomplete"
/> />
@@ -74,7 +74,7 @@
<slot name="additionRow"></slot> <slot name="additionRow"></slot>
<div class="row"> <div class="row">
<div class="hr col-12"></div> <div class="hr col-12"></div>
<chat-message <chat-messages
:chat.sync="group.chat" :chat.sync="group.chat"
:group-type="group.type" :group-type="group.type"
:group-id="group._id" :group-id="group._id"
@@ -86,16 +86,15 @@
</template> </template>
<script> <script>
import debounce from 'lodash/debounce';
import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants'; import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import externalLinks from '../../mixins/externalLinks'; import externalLinks from '../../mixins/externalLinks';
import autocomplete from '../chat/autoComplete'; import autocomplete from '../chat/autoComplete';
import communityGuidelines from './communityGuidelines'; import communityGuidelines from './communityGuidelines';
import chatMessage from '../chat/chatMessages'; import chatMessages from '../chat/chatMessages';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import markdownDirective from '@/directives/markdown'; import markdownDirective from '@/directives/markdown';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default { export default {
directives: { directives: {
@@ -104,23 +103,18 @@ export default {
components: { components: {
autocomplete, autocomplete,
communityGuidelines, communityGuidelines,
chatMessage, chatMessages,
}, },
mixins: [externalLinks], mixins: [externalLinks, autoCompleteHelperMixin],
props: ['label', 'group', 'placeholder'], props: ['label', 'group', 'placeholder'],
data () { data () {
return { return {
newMessage: '', newMessage: '',
sending: false, sending: false,
caretPosition: 0,
chat: { chat: {
submitDisable: false, submitDisable: false,
submitTimeout: null, submitTimeout: null,
}, },
coords: {
TOP: 0,
LEFT: 0,
},
textbox: null, textbox: null,
MAX_MESSAGE_LENGTH: MAX_MESSAGE_LENGTH.toString(), MAX_MESSAGE_LENGTH: MAX_MESSAGE_LENGTH.toString(),
}; };
@@ -142,35 +136,6 @@ export default {
this.handleExternalLinks(); this.handleExternalLinks();
}, },
methods: { methods: {
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
getCoord (e, text) {
this.caretPosition = text.selectionEnd;
const div = document.createElement('div');
const span = document.createElement('span');
const copyStyle = getComputedStyle(text);
[].forEach.call(copyStyle, prop => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
document.body.appendChild(div);
div.textContent = text.value.substr(0, this.caretPosition);
span.textContent = text.value.substr(this.caretPosition) || '.';
div.appendChild(span);
this.coords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
updateCarretPosition: debounce(function updateCarretPosition (eventUpdate) {
this._updateCarretPosition(eventUpdate);
}, 250),
_updateCarretPosition (eventUpdate) {
const text = eventUpdate.target;
this.getCoord(eventUpdate, text);
},
async sendMessageShortcut () { async sendMessageShortcut () {
// If the user recently pasted in the text field, don't submit // If the user recently pasted in the text field, don't submit
if (!this.chat.submitDisable) { if (!this.chat.submitDisable) {
@@ -221,50 +186,6 @@ export default {
}, 500); }, 500);
}, },
handleTab (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
if (e.shiftKey) {
this.$refs.autocomplete.selectPrevious();
} else {
this.$refs.autocomplete.selectNext();
}
}
},
handleEscape (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.cancel();
}
},
selectNextAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectNext();
}
},
selectPreviousAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectPrevious();
}
},
selectAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
if (this.$refs.autocomplete.selected !== null) {
e.preventDefault();
this.$refs.autocomplete.makeSelection();
} else {
// no autocomplete selected, newline instead
this.$refs.autocomplete.cancel();
}
}
},
selectedAutocomplete (newText, newCaret) { selectedAutocomplete (newText, newCaret) {
this.newMessage = newText; this.newMessage = newText;
// Wait for v-modal to update // Wait for v-modal to update
@@ -273,7 +194,6 @@ export default {
this.textbox.focus(); this.textbox.focus();
}); });
}, },
fetchRecentMessages () { fetchRecentMessages () {
this.$emit('fetchRecentMessages'); this.$emit('fetchRecentMessages');
}, },
@@ -284,10 +204,7 @@ export default {
beforeRouteUpdate (to, from, next) { beforeRouteUpdate (to, from, next) {
// Reset chat // Reset chat
this.newMessage = ''; this.newMessage = '';
this.coords = { this.autoCompleteMixinResetCoordsPosition();
TOP: 0,
LEFT: 0,
};
next(); next();
}, },

View File

@@ -28,7 +28,6 @@
:name="member.profile.name" :name="member.profile.name"
:backer="member.backer" :backer="member.backer"
:contributor="member.contributor" :contributor="member.contributor"
:smaller-style="true"
/> />
<inline-class-badge <inline-class-badge
v-if="member.stats" v-if="member.stats"

View File

@@ -0,0 +1,110 @@
<template>
<div
class="d-inline-flex like-button"
@click="like()"
>
<div
v-b-tooltip="{title: likeTooltip(likeCount)}"
class="d-flex"
>
<div
v-if="likeCount > 0"
class="action d-flex align-items-center mr-0"
:class="{isLiked: true, currentUserLiked: likedByCurrentUser}"
>
<div
class="svg-icon mr-1"
:title="$t('liked')"
v-html="icons.liked"
></div>
+{{ likeCount }}
</div>
<div
v-if="likeCount === 0"
class="action d-flex align-items-center mr-1"
>
<div
class="svg-icon"
:title="$t('like')"
v-html="icons.like"
></div>
</div>
</div>
<span v-if="likeCount === 0">{{ $t('like') }}</span>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.action {
display: inline-block;
margin-right: 1em;
.svg-icon {
color: $gray-100;
width: 16px;
}
&.isLiked {
color: $purple-200;
font-weight: bold;
.svg-icon {
color: $purple-300;
}
}
}
.like-button {
color: $gray-100;
font-size: 12px;
line-height: 16px;
&:hover {
cursor: pointer;
color: $purple-200;
.svg-icon {
color: $purple-300;
}
}
}
</style>
<script>
import likeIcon from '@/assets/svg/like.svg';
import likedIcon from '@/assets/svg/liked.svg';
export default {
props: {
likeCount: {
type: Number,
},
likedByCurrentUser: {
type: Boolean,
},
},
data () {
return {
icons: Object.freeze({
like: likeIcon,
liked: likedIcon,
}),
};
},
methods: {
async like () {
this.$emit('toggle-like');
},
likeTooltip (likedStatus) {
if (!likedStatus) return this.$t('like');
return null;
},
},
};
</script>

View File

@@ -1,12 +1,44 @@
<template> <template>
<div class="card-body"> <div
class="card"
:class="{
'system-message': isSystemMessage
}"
>
<div
v-b-tooltip.hover="messageDateForSystemMessage"
class="message-card"
:class="{
'user-sent-message': userSentMessage,
'user-received-message': !userSentMessage && !isSystemMessage,
'system-message': isSystemMessage
}"
>
<div
v-if="isUserMentioned"
class="mentioned-icon"
></div>
<div
v-if="userIsModerator && msg.flagCount"
class="message-hidden"
>
{{ flagCountDescription }}
</div>
<div
class="card-body"
>
<user-link <user-link
v-if="!isSystemMessage"
:user-id="msg.uuid" :user-id="msg.uuid"
:name="msg.user" :name="msg.user"
:backer="msg.backer" :backer="msg.backer"
:contributor="msg.contributor" :contributor="msg.contributor"
/> />
<p class="time"> <p
v-if="!isSystemMessage"
class="time"
>
<span <span
v-if="msg.username" v-if="msg.username"
class="mr-1" class="mr-1"
@@ -14,12 +46,86 @@
v-if="msg.username" v-if="msg.username"
class="mr-1" class="mr-1"
></span> ></span>
<span <span v-b-tooltip.hover="messageDate">{{ msg.timestamp | timeAgo }}&nbsp;</span>
v-b-tooltip.hover="messageDate" <span v-if="msg.client && user.contributor.level >= 4">
>{{ msg.timestamp | timeAgo }}&nbsp;</span> ({{ msg.client }})
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span> </span>
</p> </p>
<b-dropdown
v-if="!isSystemMessage"
right="right"
variant="flat"
toggle-class="with-icon"
class="card-menu no-min-width"
:no-caret="true"
>
<template #button-content>
<span
v-once
class="svg-icon inline menuIcon color"
v-html="icons.menuIcon"
>
</span>
</template>
<b-dropdown-item
class="selectListItem"
@click="copy(msg)"
>
<span class="with-icon">
<span
v-once
class="svg-icon icon-16 color"
v-html="icons.copy"
></span>
<span v-once>
{{ $t('copy') }}
</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="canReportMessage"
class="selectListItem custom-hover--red"
@click="report(msg)"
>
<span class="with-icon">
<span
v-once
class="svg-icon icon-16 color"
v-html="icons.report"
></span>
<span v-once>
{{ $t('report') }}
</span>
</span>
</b-dropdown-item>
<b-dropdown-item
v-if="canDeleteMessage"
class="selectListItem custom-hover--red"
@click="remove()"
>
<span class="with-icon">
<span
v-once
class="svg-icon icon-16 color"
v-html="icons.delete"
></span>
<span v-once>
{{ $t('delete') }}
</span>
</span>
</b-dropdown-item>
</b-dropdown>
<div <div
v-if="isSystemMessage"
class="system-message-body"
>
{{ msg.unformattedText }}
</div>
<div
v-else
ref="markdownContainer"
class="text markdown" class="text markdown"
dir="auto" dir="auto"
v-html="parseMarkdown(msg.text)" v-html="parseMarkdown(msg.text)"
@@ -31,43 +137,21 @@
<span v-once>{{ $t('reportedMessage') }}</span><br> <span v-once>{{ $t('reportedMessage') }}</span><br>
<span v-once>{{ $t('canDeleteNow') }}</span> <span v-once>{{ $t('canDeleteNow') }}</span>
</div> </div>
<hr>
<div <like-button
v-if="msg.id" v-if="canLikeMessage"
class="d-flex" class="mt-75"
> :liked-by-current-user="msg.likes[user._id]"
<div :like-count="likeCount"
v-if="!isMessageReported" @toggle-like="like()"
class="action d-flex align-items-center" />
@click="report(msg)"
>
<div
v-once
class="svg-icon"
v-html="icons.report"
></div>
<div v-once>
{{ $t('report') }}
</div>
</div>
<div
class="action d-flex align-items-center"
@click="remove()"
>
<div
v-once
class="svg-icon"
v-html="icons.delete"
></div>
<div v-once>
{{ $t('delete') }}
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
.message-card {
.at-highlight { .at-highlight {
background-color: rgba(213, 200, 255, 0.32); background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem; padding: 0.1rem;
@@ -76,27 +160,50 @@
.at-text { .at-text {
color: #6133b4; color: #6133b4;
} }
.card-menu button {
justify-content: center;
margin: 0;
padding: 0;
height: 1rem;
width: 1rem;
}
.markdown p:last-of-type {
margin-bottom: 0;
}
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss'; @import '~@/assets/scss/tiers.scss';
.action { .card {
display: inline-block; background: transparent !important;
color: $gray-200; margin-bottom: 0 !important;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
} }
.svg-icon { .message-card:not(.system-message) {
color: $gray-300; background: white;
margin-right: .2em; }
.mentioned-icon {
width: 16px; width: 16px;
height: 16px;
border-radius: 50%;
background-color: $purple-500;
box-shadow: 0 1px 1px 0 rgba(26, 24, 29, 0.12);
position: absolute;
right: -.5em;
top: -.5em;
} }
.message-hidden {
margin-left: 1.5em;
margin-top: 1em;
color: red;
} }
.active { .active {
@@ -107,12 +214,22 @@
} }
} }
.message-card {
border-radius: 7px;
margin: 0;
padding: 1rem 0.75rem 0.5rem 1rem;
&.system-message {
padding-top: 0.5rem;
}
.card-body { .card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem; position: relative;
padding: 0;
.time { .time {
font-size: 12px; font-size: 12px;
color: $gray-200; color: $gray-100;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -123,6 +240,23 @@
min-height: 0rem; min-height: 0rem;
} }
} }
}
.card-menu {
position: absolute;
top: 0;
right: 0;
&:not(.show) {
display: none;
}
}
.card-body:hover {
.card-menu {
display: block;
}
}
hr { hr {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -133,39 +267,146 @@
margin-top: 18px; margin-top: 18px;
color: $red-50; color: $red-50;
} }
.selectListItem:not(:hover) .svg-icon.icon-16.color {
color: #{$gray-100}
}
.custom-hover--red {
--hover-color: #{$maroon-50};
--hover-background: #{rgba($red-500, 0.25)};
}
.user-sent-message {
border: 1px solid $purple-400;
}
.system-message {
border: 1px solid $purple-400;
}
.user-received-message {
border: 1px solid $gray-500;
}
.card-menu {
// icon-color is the menu icon itself
--icon-color: #{$gray-100};
--dropdown-item-hover-icon-color: #{$gray-100};
&:hover {
--icon-color: #{$purple-300};
}
}
.menuIcon {
width: 4px;
height: 1rem;
object-fit: contain;
}
.system-message-body {
line-height: 1.71;
text-align: center;
color: $purple-300;
}
</style> </style>
<script> <script>
import axios from 'axios'; import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import { CHAT_FLAG_FROM_SHADOW_MUTE, CHAT_FLAG_LIMIT_FOR_HIDING } from '@/../../common/script/constants';
import externalLinks from '../../mixins/externalLinks'; import externalLinks from '../../mixins/externalLinks';
import { CopyToClipboardMixin } from '@/mixins/copyToClipboard';
import renderWithMentions from '@/libs/renderWithMentions'; import renderWithMentions from '@/libs/renderWithMentions';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
import userLink from '../userLink'; import userLink from '../userLink';
import deleteIcon from '@/assets/svg/delete.svg'; import deleteIcon from '@/assets/svg/delete.svg';
import reportIcon from '@/assets/svg/report.svg'; import reportIcon from '@/assets/svg/report.svg';
import menuIcon from '@/assets/svg/menu.svg';
import { userStateMixin } from '@/mixins/userState';
import copyIcon from '@/assets/svg/copy.svg';
import LikeButton from '@/components/messages/likeButton.vue';
const LikeLogicMixin = {
computed: {
likeCount () {
const message = this.msg;
if (!message.likes) return 0;
let likeCount = 0;
for (const key of Object.keys(message.likes)) {
const like = message.likes[key];
if (like) likeCount += 1;
}
return likeCount;
},
},
methods: {
async like () {
const message = cloneDeep(this.msg);
await this.$store.dispatch('chat:like', {
groupId: this.groupId,
chatMessageId: this.privateMessageMode ? message.uniqueMessageId : message.id,
});
message.likes[this.user._id] = !message.likes[this.user._id];
this.$emit('message-liked', message);
this.$root.$emit('bv::hide::tooltip');
},
},
};
export default { export default {
components: { components: {
LikeButton,
userLink, userLink,
}, },
filters: { filters: {
timeAgo (value) { timeAgo (value) {
return moment(value).fromNow(); return moment(value).fromNow();
}, },
date (value) {
// @TODO: Vue doesn't support this so we cant user preference
return moment(value).toDate().toString();
}, },
mixins: [externalLinks], },
mixins: [
externalLinks, userStateMixin, LikeLogicMixin,
CopyToClipboardMixin,
],
props: { props: {
msg: {}, msg: {
type: Object,
},
groupId: {
type: String,
},
privateMessageMode: {
type: Boolean,
},
userSentMessage: {
type: Boolean,
},
}, },
data () { data () {
return { return {
icons: Object.freeze({ icons: Object.freeze({
delete: deleteIcon, delete: deleteIcon,
report: reportIcon, report: reportIcon,
copy: copyIcon,
menuIcon,
}), }),
reported: false, reported: false,
}; };
@@ -175,19 +416,100 @@ export default {
isMessageReported () { isMessageReported () {
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported; return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
}, },
messageDateForSystemMessage () {
return this.isSystemMessage ? this.messageDate : '';
},
messageDate () { messageDate () {
const date = moment(this.msg.timestamp).toDate(); const date = moment(this.msg.timestamp).toDate();
return date.toString(); return date.toString();
}, },
userIsModerator () {
return this.hasPermission(this.user, 'moderator');
},
isSystemMessage () {
return this.msg.uuid === 'system';
},
canLikeMessage () {
if (this.isSystemMessage) {
return false;
}
if (this.privateMessageMode) {
return Boolean(this.msg.uniqueMessageId);
}
return this.msg.id;
},
canDeleteMessage () {
return this.privateMessageMode
|| this.msg.uuid === this.user._id
|| this.userIsModerator;
},
canReportMessage () {
if (this.privateMessageMode) {
return !this.isMessageReported;
}
return (this.user.flags.communityGuidelinesAccepted && this.msg.uuid !== 'system')
&& (!this.isMessageReported || this.userIsModerator);
},
isUserMentioned () {
const message = this.msg;
if (message.highlight) {
return true;
}
const { user } = this;
const displayName = user.profile.name;
const { username } = user.auth.local;
const pattern = `@(${escapeRegExp(displayName)}|${escapeRegExp(username)})(\\b)`;
message.highlight = new RegExp(pattern, 'i').test(message.text);
return message.highlight;
},
flagCountDescription () {
if (!this.msg.flagCount) {
return '';
}
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) {
return 'Message flagged once, not hidden';
}
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) {
return 'Message hidden';
}
return 'Message hidden (shadow-muted)';
},
}, },
mounted () { mounted () {
this.$emit('message-card-mounted'); this.$emit('message-card-mounted');
this.handleExternalLinks(); this.handleExternalLinks();
this.mapProfileLinksToModal();
}, },
updated () { updated () {
this.handleExternalLinks(); this.handleExternalLinks();
this.mapProfileLinksToModal();
}, },
methods: { methods: {
mapProfileLinksToModal () {
const links = this.$refs.markdownContainer.getElementsByTagName('a');
for (let i = 0; i < links.length; i += 1) {
let link = links[i].pathname;
// Internet Explorer does not provide the leading slash character in the pathname
link = link.charAt(0) === '/' ? link : `/${link}`;
if (link.startsWith('/profile/')) {
links[i].onclick = ev => {
ev.preventDefault();
this.$router.push({ path: link });
};
}
}
},
report () { report () {
this.$root.$on('habitica:report-result', data => { this.$root.$on('habitica:report-result', data => {
if (data.ok) { if (data.ok) {
@@ -199,16 +521,29 @@ export default {
this.$root.$emit('habitica::report-chat', { this.$root.$emit('habitica::report-chat', {
message: this.msg, message: this.msg,
groupId: 'privateMessage', groupId: this.groupId,
}); });
}, },
async remove () { async remove () {
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return; // eslint-disable-line no-alert // eslint-disable-next-line no-alert
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) {
return;
}
const message = this.msg; const message = this.msg;
this.$emit('message-removed', message); this.$emit('message-removed', message);
if (this.privateMessageMode) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`); await axios.delete(`/api/v4/inbox/messages/${message.id}`);
} else {
await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId,
chatId: message.id,
});
}
},
copy (message) {
this.mixinCopyToClipboard(message.text, this.$t('messageCopiedToClipboard'));
}, },
parseMarkdown (text) { parseMarkdown (text) {
return renderWithMentions(text, this.user); return renderWithMentions(text, this.user);

View File

@@ -1,9 +1,9 @@
<template> <template>
<div <div
ref="container" ref="container"
class="container-fluid" class="message-list"
> >
<div class="row loadmore"> <div class="loadmore">
<div v-if="canLoadMore && !isLoading"> <div v-if="canLoadMore && !isLoading">
<div class="loadmore-divider-holder"> <div class="loadmore-divider-holder">
<div class="loadmore-divider"></div> <div class="loadmore-divider"></div>
@@ -28,7 +28,7 @@
<div <div
v-for="(msg) in messages" v-for="(msg) in messages"
:key="msg.id" :key="msg.id"
class="row message-row" class="message-row"
:class="{ 'margin-right': user._id !== msg.uuid}" :class="{ 'margin-right': user._id !== msg.uuid}"
> >
<div <div
@@ -39,28 +39,31 @@
class="avatar-left" class="avatar-left"
:member="conversationOpponentUser" :member="conversationOpponentUser"
:avatar-only="true" :avatar-only="true"
:override-top-padding="'14px'" :show-weapon="true"
:debug-mode="false"
:override-top-padding="'0'"
:hide-class-badge="true" :hide-class-badge="true"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
<div
class="card"
:class="{'card-right': user._id !== msg.uuid, 'card-left': user._id === msg.uuid}"
>
<message-card <message-card
:msg="msg" :msg="msg"
:user-sent-message="user._id === msg.uuid"
:group-id="'privateMessage'"
:private-message-mode="true"
@message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@show-member-modal="showMemberModal" @show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted" @message-card-mounted="itemWasMounted"
/> />
</div>
<avatar <avatar
v-if="user && user._id === msg.uuid" v-if="user && user._id === msg.uuid"
class="avatar-right" class="avatar-right"
:member="user" :member="user"
:avatar-only="true" :avatar-only="true"
:show-weapon="true"
:debug-mode="false"
:hide-class-badge="true" :hide-class-badge="true"
:override-top-padding="'14px'" :override-top-padding="'0'"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
</div> </div>
@@ -71,18 +74,18 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
.avatar { .avatar-left, .avatar-right {
width: 170px; align-self: center;
min-width: 8rem;
height: 120px;
padding-top: 0 !important;
}
.avatar-right {
margin-left: -1rem;
::v-deep .character-sprites { ::v-deep .character-sprites {
margin-right: 1rem !important; margin-bottom: -5px !important;
padding-bottom: 0 !important;
margin-top: -1px !important;
}
::v-deep .avatar {
margin-left: -1.75rem;
margin-right: -0.5rem;
} }
} }
@@ -91,10 +94,19 @@
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0rem; padding: 0rem;
width: 684px; width: 684px;
} }
.message-list {
width: 100%;
padding-right: 10px;
margin-right: 0 !important;
}
.message-row { .message-row {
margin-left: 12px; margin-left: 12px;
margin-right: 12px; margin-right: 0;
margin-bottom: 1.2rem;
&:not(.margin-right) { &:not(.margin-right) {
.d-flex { .d-flex {
@@ -102,26 +114,6 @@
} }
} }
} }
@media only screen and (max-width: 1200px) {
.card {
width: 100%;
}
}
@media only screen and (min-width: 1400px) {
.message-row {
margin-left: -15px;
margin-right: -30px;
}
}
.card-left {
border: 1px solid $purple-500;
}
.card-right {
border: 1px solid $gray-500;
}
.hr { .hr {
width: 100%; width: 100%;
@@ -280,6 +272,9 @@ export default {
// container.style.overflowY = 'scroll'; // container.style.overflowY = 'scroll';
} }
}, 50), }, 50),
messageLiked (message) {
this.$emit('message-liked', message);
},
messageRemoved (message) { messageRemoved (message) {
this.$emit('message-removed', message); this.$emit('message-removed', message);
}, },

View File

@@ -20,6 +20,7 @@
}" }"
> >
<input <input
ref="textInput"
:value="value" :value="value"
class="form-control" class="form-control"
:type="inputType" :type="inputType"
@@ -29,12 +30,15 @@
}" }"
:readonly="readonly" :readonly="readonly"
:aria-readonly="readonly" :aria-readonly="readonly"
autocomplete="off"
:placeholder="placeholder" :placeholder="placeholder"
@keyup="handleChange" @keyup="handleChange"
@keyup.enter="$emit('enter')"
@blur="$emit('blur')" @blur="$emit('blur')"
> >
</div> </div>
<template v-if="!hideErrorLine">
<div <div
v-for="issue in invalidIssues" v-for="issue in invalidIssues"
:key="issue" :key="issue"
@@ -42,6 +46,7 @@
> >
{{ issue }} &nbsp; {{ issue }} &nbsp;
</div> </div>
</template>
</div> </div>
</div> </div>
</template> </template>
@@ -85,6 +90,10 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
hideErrorLine: {
type: Boolean,
default: false,
},
}, },
data () { data () {
return { return {
@@ -107,6 +116,9 @@ export default {
this.wasChanged = true; this.wasChanged = true;
this.$emit('update:value', value); this.$emit('update:value', value);
}, },
focus () {
this.$refs.textInput.focus();
},
}, },
}; };
</script> </script>
@@ -128,4 +140,12 @@ export default {
margin-bottom: 0; margin-bottom: 0;
} }
/* this removes safari "save username" UI, we only search for one, we dont want to save it */
input::-webkit-contacts-auto-fill-button,
input::-webkit-credentials-auto-fill-button {
visibility: hidden;
position: absolute;
right: 0;
}
</style> </style>

View File

@@ -29,20 +29,12 @@
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
.user-link { // this is the user name .user-link { // this is the user name
font-family: 'Roboto Condensed', sans-serif;
font-weight: bold; font-weight: bold;
margin-bottom: 0; margin-bottom: 0;
cursor: pointer; cursor: pointer;
display: inline-block;
font-size: 16px;
// currently used in the member-details-new.vue
&.smaller {
font-family: Roboto;
font-size: 14px; font-size: 14px;
font-weight: bold;
line-height: 1.71; line-height: 1.71;
} display: inline-flex !important;
&.no-tier { &.no-tier {
color: $gray-50; color: $gray-50;
@@ -111,7 +103,6 @@ export default {
'backer', 'backer',
'contributor', 'contributor',
'hideTooltip', 'hideTooltip',
'smallerStyle',
'showBuffed', 'showBuffed',
'context', 'context',
], ],
@@ -173,7 +164,7 @@ export default {
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || ''; return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
}, },
levelStyle () { levelStyle () {
return `${this.userLevelStyleFromLevel(this.level, this.isNPC)} ${this.smallerStyle ? 'smaller' : ''}`; return `${this.userLevelStyleFromLevel(this.level, this.isNPC)}`;
}, },
}, },
}; };

View File

@@ -0,0 +1,102 @@
import debounce from 'lodash/debounce';
export const autoCompleteHelperMixin = {
data () {
return {
mixinData: {
autoComplete: {
caretPosition: 0,
coords: {
TOP: 0,
LEFT: 0,
},
},
},
};
},
methods: {
autoCompleteMixinHandleTab (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
if (e.shiftKey) {
this.$refs.autocomplete.selectPrevious();
} else {
this.$refs.autocomplete.selectNext();
}
}
},
autoCompleteMixinHandleEscape (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.cancel();
}
},
autoCompleteMixinSelectNextAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectNext();
}
},
autoCompleteMixinSelectPreviousAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectPrevious();
}
},
autoCompleteMixinSelectAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
if (this.$refs.autocomplete.selected !== null) {
e.preventDefault();
this.$refs.autocomplete.makeSelection();
} else {
// no autocomplete selected, newline instead
this.$refs.autocomplete.cancel();
}
}
},
autoCompleteMixinUpdateCarretPosition: debounce(function updateCarretPosition (eventUpdate) {
this._updateCarretPosition(eventUpdate);
}, 250),
autoCompleteMixinResetCoordsPosition () {
this.mixinData.autoComplete.coords = {
TOP: 0,
LEFT: 0,
};
},
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
_getCoord (e, text) {
const caretPosition = text.selectionEnd;
this.mixinData.autoComplete.caretPosition = caretPosition;
const div = document.createElement('div');
const span = document.createElement('span');
const copyStyle = getComputedStyle(text);
[].forEach.call(copyStyle, prop => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
document.body.appendChild(div);
div.textContent = text.value.substr(0, caretPosition);
span.textContent = text.value.substr(caretPosition) || '.';
div.appendChild(span);
this.mixinData.autoComplete.coords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
_updateCarretPosition (eventUpdate) {
const text = eventUpdate.target;
this._getCoord(eventUpdate, text);
},
},
};

View File

@@ -1,7 +1,7 @@
import notifications from './notifications'; import { NotificationMixins } from './notifications';
export default { export const CopyToClipboardMixin = {
mixins: [notifications], mixins: [NotificationMixins],
methods: { methods: {
async mixinCopyToClipboard (valueToCopy, notificationToShow = null) { async mixinCopyToClipboard (valueToCopy, notificationToShow = null) {
if (navigator.clipboard) { if (navigator.clipboard) {
@@ -21,3 +21,5 @@ export default {
}, },
}, },
}; };
export default CopyToClipboardMixin;

View File

@@ -15,12 +15,26 @@
> >
{{ $t('messages') }} {{ $t('messages') }}
</h2> </h2>
<div class="placeholder svg-icon">
<!-- placeholder --> <button
</div> class="btn btn-secondary plus-button"
</div> :class="{'new-message-mode':showStartNewConversationInput}"
@click="triggerStartNewConversationState()"
>
<div <div
v-if="selectedConversation && selectedConversation.key" class="svg-icon icon-10 color"
v-html="icons.positive"
></div>
</button>
</div>
<start-new-conversation-input-header
v-if="showStartNewConversationInput"
@startNewConversation="startConversationByUsername($event)"
@cancelNewConversation="showStartNewConversationInput = false"
/>
<div
v-else-if="selectedConversation && selectedConversation.key"
class="d-flex selected-conversion" class="d-flex selected-conversion"
> >
<router-link <router-link
@@ -52,25 +66,11 @@
@change="toggleOpt()" @change="toggleOpt()"
/> />
</div> </div>
<div <pm-conversations-list
v-if="filtersConversations.length > 0" :filters-conversations="filtersConversations"
class="conversations" :selected-conversation="selectedConversation"
> @selectConversation="selectConversation($event)"
<conversation-item
v-for="conversation in filtersConversations"
:key="conversation.key"
:active-key="selectedConversation.key"
:contributor="conversation.contributor"
:backer="conversation.backer"
:uuid="conversation.key"
:display-name="conversation.name"
:username="conversation.username"
:last-message-date="conversation.date"
:last-message-text="conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : ''"
@click="selectConversation(conversation.key)"
/> />
</div>
<button <button
v-if="canLoadMoreConversations" v-if="canLoadMoreConversations"
class="btn btn-secondary" class="btn btn-secondary"
@@ -79,28 +79,35 @@
{{ $t('loadMore') }} {{ $t('loadMore') }}
</button> </button>
</div> </div>
<div class="messages-column d-flex flex-column align-items-center"> <div class="messages-column d-flex flex-column align-items-center">
<div <div
v-if="filtersConversations.length === 0 v-if="user.inbox.optOut"
&& (!selectedConversation || !selectedConversation.key)" class="disable-background-in-message-list"
class="empty-messages m-auto text-center empty-sidebar"
> >
<div class="no-messages-box"> <span
<div
v-once v-once
class="svg-icon envelope" class="caption"
v-html="icons.messageIcon" > {{ $t('PMDisabledCaptionTitle') }}. </span> &nbsp;
></div> <span
<h2 v-once> v-once
{{ $t('emptyMessagesLine1') }} class="text"
</h2> > {{ $t('PMDisabledCaptionText') }} </span>
<p v-if="!user.flags.chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
</div> </div>
<pm-empty-state
v-if="uiState === UI_STATES.NO_CONVERSATIONS"
:chat-revoked="user.flags.chatRevoked"
@newMessageClicked="showStartNewConversationInput = true"
/>
<pm-new-message-started
v-if="uiState === UI_STATES.START_NEW_CONVERSATION && selectedConversation.userStyles"
:member-obj="selectedConversation.userStyles"
/>
<div <div
v-if="filtersConversations.length !== 0 && !selectedConversation.key" v-if="uiState === UI_STATES.NO_CONVERSATIONS_SELECTED"
class="empty-messages full-height m-auto text-center" class="empty-messages full-height m-auto text-center"
> >
<div class="no-messages-box"> <div class="no-messages-box">
@@ -113,20 +120,7 @@
<p v-html="placeholderTexts.description"></p> <p v-html="placeholderTexts.description"></p>
</div> </div>
</div> </div>
<div
v-if="selectedConversation.key && selectedConversationMessages.length === 0"
class="empty-messages full-height mt-auto text-center"
>
<avatar
v-if="selectedConversation.userStyles"
:member="selectedConversation.userStyles"
:avatar-only="true"
sprites-margin="0 0 0 -45px"
class="center-avatar"
/>
<h3>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</h3>
<p>{{ $t('beginningOfConversationReminder') }}</p>
</div>
<messageList <messageList
v-if="selectedConversation && selectedConversationMessages.length > 0" v-if="selectedConversation && selectedConversationMessages.length > 0"
ref="chatscroll" ref="chatscroll"
@@ -136,16 +130,18 @@
:can-load-more="canLoadMore" :can-load-more="canLoadMore"
:is-loading="messagesLoading" :is-loading="messagesLoading"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@message-liked="messageLiked"
@triggerLoad="infiniteScrollTrigger" @triggerLoad="infiniteScrollTrigger"
/> />
<pm-disabled-state
v-if="disabledTexts?.showBottomInfo"
:disabled-texts="disabledTexts"
/>
<div <div
v-if="disabledTexts" v-if="shouldShowInputPanel"
class="pm-disabled-caption text-center" class="full-width"
> >
<h4>{{ disabledTexts.title }}</h4>
<p>{{ disabledTexts.description }}</p>
</div>
<div class="full-width">
<div <div
class="new-message-row d-flex align-items-center" class="new-message-row d-flex align-items-center"
> >
@@ -174,7 +170,7 @@
:class="{'disabled':newMessageDisabled || newMessage === ''}" :class="{'disabled':newMessageDisabled || newMessage === ''}"
@click="sendPrivateMessage()" @click="sendPrivateMessage()"
> >
{{ $t('send') }} {{ $t('sendMessage') }}
</button> </button>
</div> </div>
</div> </div>
@@ -184,8 +180,8 @@
</template> </template>
<style lang="scss"> <style lang="scss">
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors';
@import '~@/assets/scss/variables.scss'; @import '~@/assets/scss/variables';
$pmHeaderHeight: 56px; $pmHeaderHeight: 56px;
@@ -216,7 +212,11 @@
} }
.toggle-switch-outer { .toggle-switch-outer {
display: flex; display: block !important;
}
.toggle-switch {
float: right !important;
} }
} }
@@ -254,13 +254,44 @@
letter-spacing: normal; letter-spacing: normal;
color: $gray-50; color: $gray-50;
} }
.empty-messages {
flex-flow: column;
justify-content: center;
h3, p {
color: $gray-200;
margin: 0rem;
}
h2 {
color: $gray-200;
margin-bottom: 1rem;
}
.no-messages-box {
display: flex;
flex-direction: column;
align-items: center;
width: 330px;
}
.envelope {
color: $gray-400 !important;
svg {
width: 86px;
height: 64px;
}
}
}
} }
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors';
@import '~@/assets/scss/tiers.scss'; @import '~@/assets/scss/tiers';
@import '~@/assets/scss/variables.scss'; @import '~@/assets/scss/variables';
$pmHeaderHeight: 56px; $pmHeaderHeight: 56px;
$background: $white; $background: $white;
@@ -268,10 +299,15 @@
.header-bar { .header-bar {
height: 56px; height: 56px;
background-color: $white; background-color: $white;
padding-left: 1.5rem;
padding-right: 1.5rem;
align-items: center; align-items: center;
.left-header {
padding-left: 1.5rem;
max-width: 330px;
align-items: center;
flex: 1;
}
.mail-icon { .mail-icon {
width: 32px; width: 32px;
height: 24px; height: 24px;
@@ -285,6 +321,14 @@
.placeholder.svg-icon { .placeholder.svg-icon {
width: 32px; width: 32px;
} }
.plus-button {
padding: 10px 14px;
&.new-message-mode {
color: $gray-200;
}
}
} }
.full-height { .full-height {
@@ -316,42 +360,24 @@
border-bottom: 1px solid $gray-500; border-bottom: 1px solid $gray-500;
} }
.conversations { .disable-background-in-message-list {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
.empty-messages {
h3, p {
color: $gray-200;
margin: 0rem;
}
h2 {
color: $gray-200;
margin-bottom: 1rem;
}
p {
font-size: 12px;
}
.no-messages-box {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
width: 330px; justify-content: center;
height: 44px;
color: $yellow-1;
background: $yellow-500;
width: 100%;
.caption {
font-weight: 700;
line-height: 24px;
} }
.envelope { .text {
color: $gray-400 !important; font-weight: 400;
margin-bottom: 1.5rem; line-height: 24px;
::v-deep svg {
width: 64px;
height: 48px;
}
} }
} }
@@ -446,6 +472,7 @@
padding: 1.5rem; padding: 1.5rem;
.guidelines { .guidelines {
height: 32px;
font-size: 12px; font-size: 12px;
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
@@ -458,10 +485,10 @@
} }
button { button {
height: 32px;
border-radius: 4px; border-radius: 4px;
line-height: 1.714;
margin-left: 1.5rem; margin-left: 1.5rem;
padding: 2px 12px; white-space: nowrap;
&.disabled { &.disabled {
cursor: default; cursor: default;
@@ -473,30 +500,6 @@
} }
} }
.pm-disabled-caption {
padding-top: 1em;
z-index: 2;
h4, p {
color: $gray-200;
}
h4 {
margin-top: 0;
margin-bottom: 0.4em;
}
p {
font-size: 12px;
margin-bottom: 0;
}
}
.left-header {
max-width: calc(330px - 2rem); // minus the left padding
flex: 1;
}
.sidebar { .sidebar {
width: 330px; width: 330px;
background-color: $gray-700; background-color: $gray-700;
@@ -540,7 +543,7 @@
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
box-shadow: 0 3px 12px 0 rgba($black, 0.24); box-shadow: 0 3px 12px 0 rgba(26, 24, 29, 0.24);
} }
.center-avatar { .center-avatar {
@@ -549,36 +552,52 @@
</style> </style>
<script> <script>
import Vue from 'vue'; import Vue, { defineComponent } from 'vue';
import moment from 'moment'; import moment from 'moment';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import habiticaMarkdown from 'habitica-markdown';
import axios from 'axios'; import axios from 'axios';
import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants'; import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import findIndex from 'lodash/findIndex';
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.vue';
import userLink from '@/components/userLink'; import userLink from '@/components/userLink.vue';
import messageList from '@/components/messages/messageList'; import messageList from '@/components/messages/messageList.vue';
import messageIcon from '@/assets/svg/message.svg'; import messageIcon from '@/assets/svg/message.svg';
import mail from '@/assets/svg/mail.svg'; import mail from '@/assets/svg/mail.svg';
import conversationItem from '@/components/messages/conversationItem'; import faceAvatar from '@/components/faceAvatar.vue';
import faceAvatar from '@/components/faceAvatar';
import Avatar from '@/components/avatar';
import { EVENTS } from '@/libs/events'; import { EVENTS } from '@/libs/events';
import PmConversationsList from './pm-conversations-list.vue';
import PmEmptyState from './pm-empty-state.vue';
import PmDisabledState from './pm-disabled-state.vue';
import PmNewMessageStarted from './pm-new-message-started.vue';
import StartNewConversationInputHeader from './start-new-conversation-input-header.vue';
import positiveIcon from '@/assets/svg/positive.svg';
import NotificationMixins from '@/mixins/notifications';
// extract to a shared path // extract to a shared path
const CONVERSATIONS_PER_PAGE = 10; const CONVERSATIONS_PER_PAGE = 10;
const PM_PER_PAGE = 10; const PM_PER_PAGE = 10;
export default { const UI_STATES = Object.freeze({
LOADING: 'LOADING',
NO_CONVERSATIONS: 'NO_CONVERSATIONS',
NO_CONVERSATIONS_SELECTED: 'NO_CONVERSATIONS_SELECTED',
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
CONVERSATION_SELECTED: 'CONVERSATION_SELECTED',
});
export default defineComponent({
components: { components: {
Avatar, StartNewConversationInputHeader,
PmNewMessageStarted,
PmDisabledState,
PmEmptyState,
PmConversationsList,
messageList, messageList,
toggleSwitch, toggleSwitch,
conversationItem,
userLink, userLink,
faceAvatar, faceAvatar,
}, },
@@ -587,7 +606,7 @@ export default {
return moment(new Date(value)).fromNow(); return moment(new Date(value)).fromNow();
}, },
}, },
mixins: [styleHelper], mixins: [styleHelper, NotificationMixins],
beforeRouteEnter (to, from, next) { beforeRouteEnter (to, from, next) {
next(vm => { next(vm => {
const data = vm.$store.state.privateMessageOptions; const data = vm.$store.state.privateMessageOptions;
@@ -610,17 +629,26 @@ export default {
icons: Object.freeze({ icons: Object.freeze({
messageIcon, messageIcon,
mail, mail,
positive: positiveIcon,
}), }),
loaded: false, UI_STATES,
showStartNewConversationInput: false,
newConversationTargetUser: null,
loadingConversations: true,
showPopover: false, showPopover: false,
/* Conversation-specific data */ /* Conversation-specific data */
/**
* @type {PrivateMessages.InitiatedConversation}
*/
initiatedConversation: null, initiatedConversation: null,
updateConversationsCounter: 0, updateConversationsCounter: 0,
selectedConversation: {}, selectedConversation: {},
conversationPage: 0, conversationPage: 0,
canLoadMoreConversations: false, canLoadMoreConversations: false,
/** @type {PrivateMessages.ConversationSummaryMessageEntry[]} */
loadedConversations: [], loadedConversations: [],
/** @type {Record<string, PrivateMessages.PrivateMessageEntry[]>} */
messagesByConversation: {}, // cache {uuid: []} messagesByConversation: {}, // cache {uuid: []}
newMessage: '', newMessage: '',
@@ -653,9 +681,15 @@ export default {
}]; }];
} }
// Create conversation objects // Create conversation objects
/** @type {PrivateMessages.ConversationEntry[]} */
const convos = []; const convos = [];
for (const key in inboxGroup) { for (const key in inboxGroup) {
if (Object.prototype.hasOwnProperty.call(inboxGroup, key)) { if (Object.prototype.hasOwnProperty.call(inboxGroup, key)) {
/**
* @type {PrivateMessages.ConversationSummaryMessageEntry}
*/
const recentMessage = inboxGroup[key][0]; const recentMessage = inboxGroup[key][0];
const convoModel = { const convoModel = {
@@ -709,9 +743,6 @@ export default {
return ordered; return ordered;
}, },
currentLength () {
return this.newMessage.length;
},
placeholderTexts () { placeholderTexts () {
if (this.user.flags.chatRevoked) { if (this.user.flags.chatRevoked) {
return { return {
@@ -724,24 +755,22 @@ export default {
description: this.$t('PMPlaceholderDescription'), description: this.$t('PMPlaceholderDescription'),
}; };
}, },
disabledTexts () { disabledTexts () {
if (this.user.flags.chatRevoked) { if (this.user.flags.chatRevoked) {
return { return {
enableInput: false,
showBottomInfo: true,
title: this.$t('PMPlaceholderTitleRevoked'), title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('chatPrivilegesRevoked'), description: this.$t('chatPrivilegesRevoked'),
}; };
} }
if (this.user.inbox.optOut) {
return {
title: this.$t('PMDisabledCaptionTitle'),
description: this.$t('PMDisabledCaptionText'),
};
}
if (this.selectedConversation?.key) { if (this.selectedConversation?.key) {
if (this.user.inbox.blocks.includes(this.selectedConversation.key)) { if (this.user.inbox.blocks.includes(this.selectedConversation.key)) {
return { return {
enableInput: false,
showBottomInfo: true,
title: this.$t('PMDisabledCaptionTitle'), title: this.$t('PMDisabledCaptionTitle'),
description: this.$t('PMUnblockUserToSendMessages'), description: this.$t('PMUnblockUserToSendMessages'),
}; };
@@ -749,12 +778,23 @@ export default {
if (!this.selectedConversation.canReceive) { if (!this.selectedConversation.canReceive) {
return { return {
enableInput: false,
showBottomInfo: true,
title: this.$t('PMCanNotReply'), title: this.$t('PMCanNotReply'),
description: this.$t('PMUserDoesNotReceiveMessages'), description: this.$t('PMUserDoesNotReceiveMessages'),
}; };
} }
} }
if (this.user.inbox.optOut) {
return {
enableInput: true,
showBottomInfo: false,
title: this.$t('PMDisabledCaptionTitle'),
description: this.$t('PMDisabledCaptionText'),
};
}
return null; return null;
}, },
optTextSet () { optTextSet () {
@@ -776,8 +816,51 @@ export default {
return ''; return '';
}, },
newMessageDisabled () { newMessageDisabled () {
return !this.selectedConversation || !this.selectedConversation.key if (this.disabledTexts) {
|| this.disabledTexts !== null; return !this.disabledTexts.enableInput;
}
return [
UI_STATES.NO_CONVERSATIONS_SELECTED,
UI_STATES.NO_CONVERSATIONS,
UI_STATES.LOADING,
].includes(this.uiState);
},
uiState () {
if (this.loadingConversations) {
return UI_STATES.LOADING;
}
if (this.loadedConversations.length === 0) {
return UI_STATES.NO_CONVERSATIONS;
}
// Hiding the "Select a conversation on the left" state,
// and just picking the first conversation once it loads, right away
// see reload method
/* if (!this.selectedConversation.key) {
return UI_STATES.NO_CONVERSATIONS_SELECTED;
} */
if (this.selectedConversationMessages.length === 0) {
return UI_STATES.START_NEW_CONVERSATION;
}
return UI_STATES.CONVERSATION_SELECTED;
},
shouldShowInputPanel () {
const currentUiState = this.uiState;
switch (currentUiState) {
case UI_STATES.CONVERSATION_SELECTED:
case UI_STATES.START_NEW_CONVERSATION: {
return true;
}
default: {
return false;
}
}
}, },
}, },
async mounted () { async mounted () {
@@ -787,15 +870,11 @@ export default {
// notification click to refresh // notification click to refresh
this.$root.$on(EVENTS.PM_REFRESH, async () => { this.$root.$on(EVENTS.PM_REFRESH, async () => {
await this.reload(); await this.reload();
this.selectFirstConversation();
}); });
// header sync button // header sync button
this.$root.$on(EVENTS.RESYNC_COMPLETED, async () => { this.$root.$on(EVENTS.RESYNC_COMPLETED, async () => {
await this.reload(); await this.reload();
this.selectFirstConversation();
}); });
await this.reload(); await this.reload();
@@ -828,7 +907,7 @@ export default {
methods: { methods: {
async reload () { async reload () {
this.loaded = false; this.loadingConversations = true;
this.conversationPage = 0; this.conversationPage = 0;
this.loadedConversations = []; this.loadedConversations = [];
@@ -838,11 +917,15 @@ export default {
await this.$store.dispatch('user:markPrivMessagesRead'); await this.$store.dispatch('user:markPrivMessagesRead');
this.loaded = true; await this.selectFirstConversation();
this.loadingConversations = false;
}, },
async loadConversations () { async loadConversations () {
const query = ['/api/v4/inbox/conversations']; const query = [
query.push(`?page=${this.conversationPage}`); '/api/v4/inbox/conversations',
`?page=${this.conversationPage}`,
];
this.conversationPage += 1; this.conversationPage += 1;
const conversationRes = await axios.get(query.join('')); const conversationRes = await axios.get(query.join(''));
@@ -850,6 +933,12 @@ export default {
this.canLoadMoreConversations = loadedConversations.length === CONVERSATIONS_PER_PAGE; this.canLoadMoreConversations = loadedConversations.length === CONVERSATIONS_PER_PAGE;
this.loadedConversations.push(...loadedConversations); this.loadedConversations.push(...loadedConversations);
}, },
messageLiked (message) {
const messages = this.messagesByConversation[this.selectedConversation.key];
const chatIndex = findIndex(messages, chatMessage => chatMessage.id === message.id);
messages.splice(chatIndex, 1, message);
},
messageRemoved (message) { messageRemoved (message) {
const messages = this.messagesByConversation[this.selectedConversation.key]; const messages = this.messagesByConversation[this.selectedConversation.key];
@@ -916,38 +1005,43 @@ export default {
this.selectedConversation.lastMessageText = this.newMessage; this.selectedConversation.lastMessageText = this.newMessage;
this.selectedConversation.date = new Date(); this.selectedConversation.date = new Date();
this.scrollToBottom();
this.$store.dispatch('members:sendPrivateMessage', { this.$store.dispatch('members:sendPrivateMessage', {
toUserId: this.selectedConversation.key, toUserId: this.selectedConversation.key,
message: this.newMessage, message: this.newMessage,
}).then(response => { }).then(response => {
const newMessage = response.data.data.message; const newMessage = response.data.data.message;
const messageToReset = messages[messages.length - 1]; const messageToReset = messages[messages.length - 1];
messageToReset.id = newMessage.id; // just set the id, all other infos already set
messageToReset.text = newMessage.text; // handle mentions // just set the id, all other infos already set
messageToReset.id = newMessage.id;
messageToReset.text = newMessage.text;
messageToReset.uniqueMessageId = newMessage.uniqueMessageId;
Object.assign(messages[messages.length - 1], messageToReset); Object.assign(messages[messages.length - 1], messageToReset);
this.updateConversationsCounter += 1; this.updateConversationsCounter += 1;
}); });
this.newMessage = ''; this.newMessage = '';
setTimeout(() => {
this.scrollToBottom();
}, 150);
}, },
scrollToBottom () { scrollToBottom () {
if (!this.$refs.chatscroll) {
return;
}
const chatscrollBeforeTick = this.$refs.chatscroll.$el;
chatscrollBeforeTick.scrollTop = chatscrollBeforeTick.scrollHeight;
Vue.nextTick(() => { Vue.nextTick(() => {
if (!this.$refs.chatscroll) return; if (!this.$refs.chatscroll) {
return;
}
const chatscroll = this.$refs.chatscroll.$el; const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight; chatscroll.scrollTop = chatscroll.scrollHeight;
}); });
}, },
removeTags (html) {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
infiniteScrollTrigger () { infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced // show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading) // or else it would trigger on every scrolling-pixel (while not loading)
@@ -983,11 +1077,81 @@ export default {
this.selectedConversation.canLoadMore = loadedMessages.length === PM_PER_PAGE; this.selectedConversation.canLoadMore = loadedMessages.length === PM_PER_PAGE;
this.messagesLoading = false; this.messagesLoading = false;
}, },
selectFirstConversation () { async selectFirstConversation () {
if (this.loadedConversations.length > 0) { if (this.loadedConversations.length > 0) {
this.selectConversation(this.loadedConversations[0].uuid, true); await this.selectConversation(this.loadedConversations[0].uuid, true);
} }
}, },
triggerStartNewConversationState () {
this.showStartNewConversationInput = true;
}, },
async startConversationByUsername (targetUserName) {
// check if the target user exists in current conversations, select that conversation
/** @type {PrivateMessages.ConversationSummaryMessageEntry} */
const foundConversation = this.loadedConversations.find(c => c.username === targetUserName);
if (foundConversation) {
this.selectConversation(foundConversation.uuid);
this.showStartNewConversationInput = false;
return;
}
let loadedMember = null;
try {
loadedMember = await this.$store.dispatch('members:fetchMemberByUsername', {
username: targetUserName,
});
} catch {
loadedMember = null;
}
if (!loadedMember) {
this.error(this.$t('targetUserNotExist', { userName: targetUserName }));
return;
}
const loadedMemberUUID = loadedMember.id;
this.showStartNewConversationInput = false;
// otherwise create a dummy conversation, load messages for that user
/**
* @type {PrivateMessages.ConversationSummaryMessageEntry}
*/
const newConversationItem = {
uuid: loadedMemberUUID,
user: loadedMember.profile.name,
username: loadedMember.auth.local.username,
contributor: loadedMember.contributor,
userStyles: loadedMember,
canReceive: loadedMember.inbox.canReceive,
timestamp: new Date(),
count: 0,
text: '',
}; };
this.loadedConversations.splice(0, 0, newConversationItem);
this.selectConversation(loadedMemberUUID);
if (this.messagesByConversation[loadedMemberUUID]) {
const messageLengthByConversation = this.messagesByConversation[loadedMemberUUID].length;
// if messages already exists, update the sidebar entry last message
if (messageLengthByConversation > 0) {
/** @type {PrivateMessages.PrivateMessageEntry} */
const lastMessage = this.messagesByConversation[loadedMemberUUID][messageLengthByConversation - 1];
newConversationItem.lastMessageText = lastMessage.text;
return;
}
}
this.newConversationTargetUser = loadedMember;
},
},
});
</script> </script>

View File

@@ -62,7 +62,7 @@
<script> <script>
import moment from 'moment'; import moment from 'moment';
import userLabel from '../userLabel'; import userLabel from '../../components/userLabel.vue';
import dots from '@/assets/svg/dots.svg'; import dots from '@/assets/svg/dots.svg';
import block from '@/assets/svg/block.svg'; import block from '@/assets/svg/block.svg';
@@ -117,7 +117,7 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors';
.action-padding { .action-padding {
height: 24px !important; height: 24px !important;
@@ -153,7 +153,7 @@ export default {
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors';
.conversation { .conversation {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;

View File

@@ -0,0 +1,64 @@
<template>
<div
v-if="filtersConversations.length > 0"
class="conversations"
>
<conversation-item
v-for="conversation in filtersConversations"
:key="conversation.key"
:active-key="selectedConversation?.key"
:contributor="conversation.contributor"
:backer="conversation.backer"
:uuid="conversation.key"
:display-name="conversation.name"
:username="conversation.username"
:last-message-date="conversation.date"
:last-message-text="conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : ''"
@click="selectConversation(conversation.key)"
/>
</div>
</template>
<style scoped lang="scss">
.conversations {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
</style>
<script>
import { defineComponent } from 'vue';
import habiticaMarkdown from 'habitica-markdown';
import conversationItem from '@/pages/private-messages/pm-conversation-item.vue';
export default defineComponent({
components: { conversationItem },
props: {
filtersConversations: {
type: Array,
default: () => [],
},
selectedConversation: {
type: Object,
default: null,
},
},
methods: {
removeTags (html) {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
selectConversation (conversationKey) {
this.$emit('selectConversation', conversationKey);
},
},
});
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div
class="pm-disabled-caption text-center"
>
<h4>{{ disabledTexts.title }}</h4>
<p>{{ disabledTexts.description }}</p>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
.pm-disabled-caption {
padding-top: 1.5em;
z-index: 2;
h4, p {
color: $gray-200;
}
h4 {
margin-top: 0;
margin-bottom: 0.4em;
}
p {
font-size: 12px;
margin-bottom: 0;
}
}
</style>
<script>
export default {
props: ['disabledTexts'],
};
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div
class="empty-messages m-auto text-center empty-sidebar"
>
<div class="no-messages-box">
<div
v-once
class="svg-icon envelope mb-4"
v-html="icons.mailIcon"
></div>
<strong
v-once
class="mb-1"
>
{{ $t('emptyMessagesLine1') }}
</strong>
<p v-if="!chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
<button
class="btn btn-primary mt-4 d-flex align-items-center"
@click="$emit('newMessageClicked')"
>
<div
class="svg-icon icon-10 color mr-2"
v-html="icons.positive"
></div>
{{ $t('newMessage') }}
</button>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
strong {
line-height: 1.71;
color: $gray-100;
}
.svg-icon.icon-10 {
margin: 3px;
}
p {
font-size: 14px;
font-weight: 400;
line-height: 24px;
}
</style>
<script>
import mailIcon from '@/assets/svg/mail.svg';
import positiveIcon from '@/assets/svg/positive.svg';
export default {
props: {
chatRevoked: Boolean,
},
data () {
return {
icons: Object.freeze({
mailIcon,
positive: positiveIcon,
}),
};
},
};
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div
v-once
class="centered empty-messages m-auto text-center"
>
<avatar
v-if="memberObj"
:member="memberObj"
:avatar-only="true"
:show-weapon="false"
:hide-class-badge="true"
:override-top-padding="'0px'"
:sprites-margin="'0 0 0 -30px'"
:debug-mode="false"
:center-avatar="true"
class="mb-3"
/>
<strong>{{ memberObj.profile.name }}</strong>
<div class="username mb-3">
@{{ memberObj.auth.local.username }}
</div>
<div
class="kind-text"
v-html="$t('rememberToBeKind')"
></div>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
.centered {
align-content: center;
}
.center-avatar {
margin: 0 auto;
}
strong {
line-height: 1.71;
}
.username {
font-size: 12px;
line-height: 1.33;
color: $gray-100;
margin-top: -4px;
}
.kind-text {
width: 330px;
line-height: 1.71;
color: $gray-100;
}
</style>
<script>
import Avatar from '@/components/avatar.vue';
export default {
components: { Avatar },
props: {
memberObj: null,
},
};
</script>

View File

@@ -0,0 +1,44 @@
export namespace PrivateMessages {
// Shared properties between message types
interface SharedMessageProps {
username: string;
contributor: Record<string, unknown>;
userStyles: Record<string, unknown>;
canReceive: boolean;
}
/**
* This is the Type we get from our API
*/
interface ConversationSummaryMessageEntry extends SharedMessageProps {
uuid: string;
user: string;
timestamp: string;
text: string;
count: number;
}
/**
* The Visual (Sidebar) Entry
*/
interface ConversationEntry extends SharedMessageProps {
/**
* UUID
*/
key: string;
name: string;
lastMessageText: '',
canLoadMore: boolean;
page: 0
}
/**
* Loaded Private Messages, partial type
*/
interface PrivateMessageEntry extends SharedMessageProps {
text: string;
}
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="ml-4">
<strong
v-once
v-html="$t('to')"
></strong>
<validated-text-input
id="selectUser"
ref="targetUserInput"
v-model="targetUserInputValue"
class="mx-2"
:is-valid="foundUser._id"
:only-show-invalid-state="foundUser._id === undefined"
:hide-error-line="true"
:placeholder="$t('usernameOrUserId')"
:invalid-issues="userInputInvalidIssues"
@enter="triggerNewConversation"
/>
<button
class="btn btn-primary"
:disabled="preventTrigger"
@click="triggerNewConversation()"
>
{{ $t('confirm') }}
</button>
<button
class="ml-2 btn btn-secondary"
@click="$emit('cancelNewConversation')"
>
{{ $t('cancel') }}
</button>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
div {
display: flex;
align-items: center;
}
div > * {
height: 32px;
}
strong {
line-height: 1.71;
align-content: center;
}
input {
border-radius: 2px;
border-width: 2px;
width: 420px;
}
#selectUser {
/* changing the style of validate-text-input to the same as others */
::v-deep {
.input-group {
border-width: 2px;
input {
width: 420px;
height: 100%;
color: $gray-50;
}
}
.input-group {
&:focus, &:active, &:focus-within {
border: solid 2px $purple-400;
}
}
}
}
</style>
<script>
import debounce from 'lodash/debounce';
import isUUID from 'validator/es/lib/isUUID';
import ValidatedTextInput from '@/components/ui/validatedTextInput.vue';
export default {
components: {
ValidatedTextInput,
},
mixins: [],
data () {
return {
targetUserInputValue: '',
userNotFound: false,
foundUser: {},
};
},
computed: {
preventTrigger () {
return this.targetUserInputValue.length < 2;
},
userInputInvalidIssues () {
return this.targetUserInputValue.length > 0 && this.userNotFound
? [this.$t('userWithUsernameOrUserIdNotFound')]
: [''];
},
},
watch: {
targetUserInputValue: {
handler () {
this.searchUser(this.targetUserInputValue.replace('@', ''));
},
},
},
mounted () {
this.$refs.targetUserInput.focus();
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'select-user-modal');
},
searchUser: debounce(async function userSearch (searchTerm = '') {
this.foundUser = {};
if (searchTerm.length < 1) {
this.userNotFound = false;
return;
}
let result;
if (isUUID(searchTerm)) {
try {
result = await this.$store.dispatch('members:fetchMember', {
memberId: searchTerm,
});
} catch {
result = null;
}
} else {
try {
result = await this.$store.dispatch('members:fetchMemberByUsername', {
username: searchTerm,
});
} catch {
result = null;
}
}
if (!result) {
this.userNotFound = true;
return;
}
this.userNotFound = false;
this.foundUser = result;
}, 500),
triggerNewConversation () {
const userWithoutAt = this.$refs.targetUserInput.value.replace('@', '');
this.$emit('startNewConversation', userWithoutAt);
},
},
};
</script>

View File

@@ -49,7 +49,7 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation'); const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing'); const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages'); const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages/index.vue');
// Challenges // Challenges
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index'); const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');

View File

@@ -43,7 +43,14 @@ export async function deleteChat (store, payload) {
} }
export async function like (store, payload) { export async function like (store, payload) {
const url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatId}/like`; let url = '';
if (payload.groupId === 'privateMessage') {
url = `/api/v4/inbox/like-private-message/${payload.chatMessageId}`;
} else {
url = `/api/v4/groups/${payload.groupId}/chat/${payload.chatMessageId}/like`;
}
const response = await axios.post(url); const response = await axios.post(url);
return response.data.data; return response.data.data;
} }

View File

@@ -1,14 +1,16 @@
import Vue from 'vue'; import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import ChatCard from '@/components/chat/chatCard.vue'; import BootstrapVue from 'bootstrap-vue';
import MessageCard from '@/components/messages/messageCard.vue';
import Store from '@/libs/store'; import Store from '@/libs/store';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Store); localVue.use(Store);
localVue.use(Vue.directive('b-tooltip', {})); localVue.use(Vue.directive('b-tooltip', {}));
localVue.use(BootstrapVue);
describe('ChatCard', () => { describe('MessageCard', () => {
function createMessage (text) { function createMessage (text) {
return { text, likes: {} }; return { text, likes: {} };
} }
@@ -26,7 +28,7 @@ describe('ChatCard', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(ChatCard, { wrapper = shallowMount(MessageCard, {
propsData: { msg: message }, propsData: { msg: message },
store: new Store({ store: new Store({
state: { state: {

View File

@@ -207,7 +207,8 @@
"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": "You can send a new message to a user by visiting their profile and clicking the \"Message\" button.", "emptyMessagesLine2": "Send a message to start a conversation with your Party members or another Habitica player",
"newMessage": "New Message",
"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",
@@ -238,5 +239,7 @@
"submitQuestion": "Submit Question", "submitQuestion": "Submit Question",
"whyReportingPlayer": "Why are you reporting this player?", "whyReportingPlayer": "Why are you reporting this player?",
"whyReportingPlayerPlaceholder": "Reason for report", "whyReportingPlayerPlaceholder": "Reason for report",
"playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habiticas Community Guidelines." "playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habiticas Community Guidelines.",
"targetUserNotExist": "Target User: '<%= userName %>' does not exist.",
"rememberToBeKind": "Please remember to be kind, respectful, and follow the <a href='/static/community-guidelines' target='_blank'>Community Guidelines</a>."
} }

View File

@@ -114,9 +114,7 @@
"whyReportingPostPlaceholder": "Reason for report", "whyReportingPostPlaceholder": "Reason for report",
"optional": "Optional", "optional": "Optional",
"needsTextPlaceholder": "Type your message here.", "needsTextPlaceholder": "Type your message here.",
"copyMessageAsToDo": "Copy message as To Do", "messageCopiedToClipboard": "Message copied to clipboard.",
"copyAsTodo": "Copy as To Do",
"messageAddedAsToDo": "Message copied as To Do.",
"leaderOnlyChallenges": "Only group leader can create challenges", "leaderOnlyChallenges": "Only group leader can create challenges",
"sendGift": "Send a Gift", "sendGift": "Send a Gift",
"selectGift": "Select Gift", "selectGift": "Select Gift",

View File

@@ -51,8 +51,6 @@
"messageNotAbleToBuyInBulk": "This item cannot be purchased in quantities above 1.", "messageNotAbleToBuyInBulk": "This item cannot be purchased in quantities above 1.",
"notificationsRequired": "Notification ids are required.", "notificationsRequired": "Notification ids are required.",
"unallocatedStatsPoints": "You have <span class=\"notification-bold-blue\"><%= points %> unallocated Stat Points</span>", "unallocatedStatsPoints": "You have <span class=\"notification-bold-blue\"><%= points %> unallocated Stat Points</span>",
"beginningOfConversation": "This is the beginning of your conversation with <%= userName %>.",
"beginningOfConversationReminder": "Remember to be kind, respectful, and follow the Community Guidelines!",
"messageDeletedUser": "Sorry, this user has deleted their account.", "messageDeletedUser": "Sorry, this user has deleted their account.",
"messageMissingDisplayName": "Missing display name.", "messageMissingDisplayName": "Missing display name.",
"reportedMessage": "You have reported this message to moderators.", "reportedMessage": "You have reported this message to moderators.",

View File

@@ -1,5 +1,10 @@
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import * as inboxLib from '../../libs/inbox'; import * as inboxLib from '../../libs/inbox';
import { sanitizeText as sanitizeMessageText } from '../../models/message';
import highlightMentions from '../../libs/highlightMentions';
import { model as User } from '../../models/user';
import { NotAuthorized, NotFound } from '../../libs/errors';
import { sentMessage } from '../../libs/inbox';
const api = {}; const api = {};
@@ -33,4 +38,44 @@ api.getInboxMessages = {
}, },
}; };
/**
* @api {post} /api/v3/members/send-private-message Send a private message to a member
* @apiName SendPrivateMessage
* @apiGroup Member
*
* @apiParam (Body) {String} message The message
* @apiParam (Body) {UUID} toUserId The id of the user to contact
*
* @apiSuccess {Object} data.message The message just sent
*
* @apiUse UserNotFound
*/
api.sendPrivateMessage = {
method: 'POST',
url: '/members/send-private-message',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkBody('message', res.t('messageRequired')).notEmpty();
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const sender = res.locals.user;
const sanitizedMessageText = sanitizeMessageText(req.body.message);
const message = (await highlightMentions(sanitizedMessageText))[0];
const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0]));
const messageSent = await sentMessage(sender, receiver, message, res.t);
res.respond(200, { message: messageSent });
},
};
export default api; export default api;

View File

@@ -22,11 +22,6 @@ import {
} from '../../libs/email'; } from '../../libs/email';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import common from '../../../common'; import common from '../../../common';
import { sentMessage } from '../../libs/inbox';
import {
sanitizeText as sanitizeMessageText,
} from '../../models/message';
import highlightMentions from '../../libs/highlightMentions';
import { handleGetMembersForChallenge } from '../../libs/challenges/handleGetMembersForChallenge'; import { handleGetMembersForChallenge } from '../../libs/challenges/handleGetMembersForChallenge';
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
@@ -105,7 +100,7 @@ const api = {};
api.getMember = { api.getMember = {
method: 'GET', method: 'GET',
url: '/members/:memberId', url: '/members/:memberId',
middlewares: [], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
@@ -134,7 +129,7 @@ api.getMember = {
api.getMemberByUsername = { api.getMemberByUsername = {
method: 'GET', method: 'GET',
url: '/members/username/:username', url: '/members/username/:username',
middlewares: [], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('username', res.t('invalidReqParams')).notEmpty(); req.checkParams('username', res.t('invalidReqParams')).notEmpty();
@@ -146,15 +141,25 @@ api.getMemberByUsername = {
const member = await User const member = await User
.findOne({ 'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true }) .findOne({ 'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true })
.select(memberFields) .select(`${memberFields} blocks`)
.exec(); .exec();
if (!member) throw new NotFound(res.t('userNotFound')); if (!member) throw new NotFound(res.t('userNotFound'));
const blocksArray = member.blocks || [];
delete member.blocks;
// manually call toJSON with minimize: true so empty paths aren't returned // manually call toJSON with minimize: true so empty paths aren't returned
const memberToJSON = member.toJSON({ minimize: true }); const memberToJSON = member.toJSON({ minimize: true });
User.addComputedStatsToJSONObj(memberToJSON.stats, member); User.addComputedStatsToJSONObj(memberToJSON.stats, member);
const { user } = res.locals;
const isRequestingUserBlocked = blocksArray.includes(user._id);
memberToJSON.inbox.canReceive = !(memberToJSON.inbox.optOut || isRequestingUserBlocked) || user.hasPermission('moderator');
res.respond(200, memberToJSON); res.respond(200, memberToJSON);
}, },
}; };
@@ -253,7 +258,7 @@ api.getMemberByUsername = {
api.getMemberAchievements = { api.getMemberAchievements = {
method: 'GET', method: 'GET',
url: '/members/:memberId/achievements', url: '/members/:memberId/achievements',
middlewares: [], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
@@ -638,46 +643,6 @@ api.getObjectionsToInteraction = {
}, },
}; };
/**
* @api {post} /api/v3/members/send-private-message Send a private message to a member
* @apiName SendPrivateMessage
* @apiGroup Member
*
* @apiParam (Body) {String} message The message
* @apiParam (Body) {UUID} toUserId The id of the user to contact
*
* @apiSuccess {Object} data.message The message just sent
*
* @apiUse UserNotFound
*/
api.sendPrivateMessage = {
method: 'POST',
url: '/members/send-private-message',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkBody('message', res.t('messageRequired')).notEmpty();
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const sender = res.locals.user;
const sanitizedMessageText = sanitizeMessageText(req.body.message);
const message = (await highlightMentions(sanitizedMessageText))[0];
const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound'));
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0]));
const messageSent = await sentMessage(sender, receiver, message, res.t);
res.respond(200, { message: messageSent });
},
};
/** /**
* @api {post} /api/v3/members/transfer-gems Send a gem gift to a member * @api {post} /api/v3/members/transfer-gems Send a gem gift to a member
* @apiName TransferGems * @apiName TransferGems

View File

@@ -1,10 +1,14 @@
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import { apiError } from '../../libs/apiError'; import { apiError } from '../../libs/apiError';
import { import { NotFound } from '../../libs/errors';
NotFound,
} from '../../libs/errors';
import { listConversations } from '../../libs/inbox/conversation.methods'; import { listConversations } from '../../libs/inbox/conversation.methods';
import { clearPMs, deleteMessage, getUserInbox } from '../../libs/inbox'; import {
applyLikeToMessages,
clearPMs, deleteMessage, getUserInbox,
} from '../../libs/inbox';
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
import * as inboxLib from '../../libs/inbox';
import logger from '../../libs/logger';
const api = {}; const api = {};
@@ -93,6 +97,7 @@ api.clearMessages = {
* {"success":true,"data":[ * {"success":true,"data":[
* { * {
* "_id":"8a9d461b-f5eb-4a16-97d3-c03380c422a3", * "_id":"8a9d461b-f5eb-4a16-97d3-c03380c422a3",
* "uuid":"8a9d461b-f5eb-4a16-97d3-c03380c422a3",
* "user":"user display name", * "user":"user display name",
* "username":"some_user_name", * "username":"some_user_name",
* "timestamp":"12315123123", * "timestamp":"12315123123",
@@ -147,4 +152,94 @@ api.getInboxMessages = {
}, },
}; };
/**
* @apiIgnore
* @api {post} /api/v4/members/flag-private-message/:messageId Flag a private message
* @apiDescription Moderators are notified about every flagged message,
* including the sender, recipient, and full content of the message.
* This is for API v4 which must not be used in third-party tools as it can change without notice.
* There is no equivalent route in API v3.
* @apiName FlagPrivateMessage
* @apiGroup Member
*
* @apiParam (Path) {UUID} messageId The private message id
*
* @apiSuccess {Object} data The flagged private message
* @apiSuccess {UUID} data.id The id of the message
* @apiSuccess {String} data.text The text of the message
* @apiSuccess {Number} data.timestamp The timestamp of the message in milliseconds
* @apiSuccess {Object} data.likes The likes of the message (always an empty object)
* @apiSuccess {Object} data.flags The flags of the message
* @apiSuccess {Number} data.flagCount The number of flags the message has
* @apiSuccess {UUID} data.uuid The User ID of the author of the message,
* or of the recipient if `sent` is true
* @apiSuccess {String} data.user The Display Name of the author of the message,
* or of the recipient if `sent` is true
* @apiSuccess {String} data.username The Username of the author of the message,
* or of the recipient if `sent` is true
*
* @apiUse MessageNotFound
* @apiUse MessageIdRequired
* @apiError (400) {BadRequest} messageGroupChatFlagAlreadyReported You have already
* reported this message
*/
api.flagPrivateMessage = {
method: 'POST',
url: '/members/flag-private-message/:messageId',
middlewares: [authWithHeaders()],
async handler (req, res) {
const chatReporter = chatReporterFactory('Inbox', req, res);
const message = await chatReporter.flag();
res.respond(200, {
ok: true,
message,
});
},
};
/**
* @api {post} /api/v4//inbox/like-private-message/:uniqueMessageId Like a private message
* @apiName LikePrivateMessage
* @apiGroup Inbox
* @apiDescription Likes a private message, this uses the uniqueMessageId which is a shared ID
* between message copies of both chat participants
*
* @apiParam (Path) {UUID} uniqueMessageId This is NOT private message.id,
* but rather message.uniqueMessageId
*
* @apiSuccess {Object} data The liked <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/message.js#L42' target='_blank'>private message</a>
*
* @apiUse MessageNotFound
*/
api.likePrivateMessage = {
method: 'POST',
url: '/inbox/like-private-message/:uniqueMessageId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('uniqueMessageId', apiError('messageIdRequired')).notEmpty();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { user } = res.locals;
const { uniqueMessageId } = req.params;
const messages = await inboxLib.getInboxMessagesByUniqueId(uniqueMessageId);
if (messages.length === 0) {
throw new NotFound(res.t('messageGroupChatNotFound'));
}
if (messages.length > 2) {
logger.error(`More than 2 Messages exist with this uniqueMessageId: ${uniqueMessageId} check in Database!`);
}
await applyLikeToMessages(user, messages);
const messageToReturn = messages.find(m => m.uuid === user._id);
res.respond(200, messageToReturn);
},
};
export default api; export default api;

View File

@@ -1,55 +1,9 @@
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
import { ensurePermission } from '../../middlewares/ensureAccessRight'; import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { TransactionModel as Transaction } from '../../models/transaction'; import { TransactionModel as Transaction } from '../../models/transaction';
const api = {}; const api = {};
/**
* @apiIgnore
* @api {post} /api/v4/members/flag-private-message/:messageId Flag a private message
* @apiDescription Moderators are notified about every flagged message,
* including the sender, recipient, and full content of the message.
* This is for API v4 which must not be used in third-party tools as it can change without notice.
* There is no equivalent route in API v3.
* @apiName FlagPrivateMessage
* @apiGroup Member
*
* @apiParam (Path) {UUID} messageId The private message id
*
* @apiSuccess {Object} data The flagged private message
* @apiSuccess {UUID} data.id The id of the message
* @apiSuccess {String} data.text The text of the message
* @apiSuccess {Number} data.timestamp The timestamp of the message in milliseconds
* @apiSuccess {Object} data.likes The likes of the message (always an empty object)
* @apiSuccess {Object} data.flags The flags of the message
* @apiSuccess {Number} data.flagCount The number of flags the message has
* @apiSuccess {UUID} data.uuid The User ID of the author of the message,
* or of the recipient if `sent` is true
* @apiSuccess {String} data.user The Display Name of the author of the message,
* or of the recipient if `sent` is true
* @apiSuccess {String} data.username The Username of the author of the message,
* or of the recipient if `sent` is true
*
* @apiUse MessageNotFound
* @apiUse MessageIdRequired
* @apiError (400) {BadRequest} messageGroupChatFlagAlreadyReported You have already
* reported this message
*/
api.flagPrivateMessage = {
method: 'POST',
url: '/members/flag-private-message/:messageId',
middlewares: [authWithHeaders()],
async handler (req, res) {
const chatReporter = chatReporterFactory('Inbox', req, res);
const message = await chatReporter.flag();
res.respond(200, {
ok: true,
message,
});
},
};
/** /**
* @api {get} /api/v4/user/purchase-history Get users purchase history * @api {get} /api/v4/user/purchase-history Get users purchase history
* @apiName UserGetPurchaseHistory * @apiName UserGetPurchaseHistory

View File

@@ -8,7 +8,7 @@ import common from '../../common';
const commonErrors = common.errorMessages.common; const commonErrors = common.errorMessages.common;
const apiErrors = common.errorMessages.api; const apiErrors = common.errorMessages.api;
function apiError (msgKey, vars = {}) { export function apiError (msgKey, vars = {}) {
let message = apiErrors[msgKey]; let message = apiErrors[msgKey];
if (!message) message = commonErrors[msgKey]; if (!message) message = commonErrors[msgKey];
if (!message) throw new Error(`Error processing the API message "${msgKey}".`); if (!message) throw new Error(`Error processing the API message "${msgKey}".`);
@@ -18,7 +18,3 @@ function apiError (msgKey, vars = {}) {
// TODO cache the result of template() ? More memory usage, faster output // TODO cache the result of template() ? More memory usage, faster output
return _.template(message)(clonedVars); return _.template(message)(clonedVars);
} }
export {
apiError,
};

View File

@@ -1,6 +1,6 @@
import { mapInboxMessage, inboxModel as Inbox } from '../../models/message'; import { mapInboxMessage, inboxModel } from '../../models/message';
import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disable-line import/no-cycle import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disable-line import/no-cycle
import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle import { sendNotification as sendPushNotification } from '../pushNotifications';
export async function sentMessage (sender, receiver, message, translate) { export async function sentMessage (sender, receiver, message, translate) {
const messageSent = await sender.sendMessage(receiver, { receiverMsg: message }); const messageSent = await sender.sendMessage(receiver, { receiverMsg: message });
@@ -50,7 +50,7 @@ export async function getUserInbox (user, optionParams = getUserInboxDefaultOpti
findObj.uuid = options.conversation; findObj.uuid = options.conversation;
} }
let query = Inbox let query = inboxModel
.find(findObj) .find(findObj)
.sort({ timestamp: -1 }); .sort({ timestamp: -1 });
@@ -81,14 +81,50 @@ export async function getUserInbox (user, optionParams = getUserInboxDefaultOpti
return messagesObj; return messagesObj;
} }
export async function applyLikeToMessages (user, uniqueMessages) {
const bulkWriteOperations = [];
for (const message of uniqueMessages) {
if (!message.likes) {
message.likes = {};
}
message.likes[user._id] = !message.likes[user._id];
bulkWriteOperations.push({
updateOne: {
filter: { _id: message._id },
update: {
$set: {
likes: message.likes,
},
},
},
});
}
await inboxModel.bulkWrite(bulkWriteOperations, {});
}
export async function getInboxMessagesByUniqueId (uniqueMessageId) {
return inboxModel
.find({ uniqueMessageId })
// prevents creating the proxies, no .save() and other stuff
.lean()
// since there can be only 2 messages maximum for this uniqueMessageId,
// this might speed up the query
.limit(2)
.exec();
}
export async function getUserInboxMessage (user, messageId) { export async function getUserInboxMessage (user, messageId) {
return Inbox.findOne({ ownerId: user._id, _id: messageId }).exec(); return inboxModel.findOne({ ownerId: user._id, _id: messageId }).exec();
} }
export async function deleteMessage (user, messageId) { export async function deleteMessage (user, messageId) {
const message = await Inbox.findOne({ _id: messageId, ownerId: user._id }).exec(); const message = await inboxModel.findOne({ _id: messageId, ownerId: user._id }).exec();
if (!message) return false; if (!message) return false;
await Inbox.deleteOne({ _id: message._id, ownerId: user._id }).exec(); await inboxModel.deleteOne({ _id: message._id, ownerId: user._id }).exec();
return true; return true;
} }
@@ -98,6 +134,6 @@ export async function clearPMs (user) {
await Promise.all([ await Promise.all([
user.save(), user.save(),
Inbox.deleteMany({ ownerId: user._id }).exec(), inboxModel.deleteMany({ ownerId: user._id }).exec(),
]); ]);
} }

View File

@@ -45,6 +45,7 @@ const inboxSchema = new mongoose.Schema({
// we store two copies of each inbox messages: // we store two copies of each inbox messages:
// one for the sender and one for the receiver // one for the sender and one for the receiver
ownerId: { $type: String, ref: 'User' }, ownerId: { $type: String, ref: 'User' },
uniqueMessageId: String,
...defaultSchema(), ...defaultSchema(),
}, { }, {
minimize: false, // Allow for empty flags to be saved minimize: false, // Allow for empty flags to be saved

11
website/server/models/typedefs.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
// This file is just to improve the dev-experiences when working with untyped js classes
// These Types don't have to have all properties (for now) but should be extended once the
// type is used in any new method
interface InboxMessage {
id: string;
uniqueMessageId: string;
likes: {
[userId: string]: boolean
}
}

View File

@@ -2,6 +2,7 @@ import moment from 'moment';
import { import {
defaults, map, flatten, flow, compact, uniq, partialRight, remove, defaults, map, flatten, flow, compact, uniq, partialRight, remove,
} from 'lodash'; } from 'lodash';
import { v4 as uuid } from 'uuid';
import common from '../../../common'; import common from '../../../common';
import { // eslint-disable-line import/no-cycle import { // eslint-disable-line import/no-cycle
@@ -125,8 +126,11 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
// whether to save users after sending the message, defaults to true // whether to save users after sending the message, defaults to true
const saveUsers = options.save !== false; const saveUsers = options.save !== false;
const uniqueMessageId = uuid();
const newReceiverMessage = new Inbox({ const newReceiverMessage = new Inbox({
ownerId: userToReceiveMessage._id, ownerId: userToReceiveMessage._id,
uniqueMessageId,
}); });
Object.assign(newReceiverMessage, messageDefaults(options.receiverMsg, sender)); Object.assign(newReceiverMessage, messageDefaults(options.receiverMsg, sender));
setUserStyles(newReceiverMessage, sender); setUserStyles(newReceiverMessage, sender);
@@ -165,6 +169,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
newSenderMessage = new Inbox({ newSenderMessage = new Inbox({
sent: true, sent: true,
ownerId: sender._id, ownerId: sender._id,
uniqueMessageId,
}); });
Object.assign(newSenderMessage, messageDefaults(senderMsg, userToReceiveMessage)); Object.assign(newSenderMessage, messageDefaults(senderMsg, userToReceiveMessage));
setUserStyles(newSenderMessage, sender); setUserStyles(newSenderMessage, sender);