mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 05:07:22 +01:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
56
test/api/v3/integration/members/GET-members_username.test.js
Normal file
56
test/api/v3/integration/members/GET-members_username.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
104
test/api/v4/inbox/POST-inbox_message_like.test.js
Normal file
104
test/api/v4/inbox/POST-inbox_message_like.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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 }} </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>
|
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
110
website/client/src/components/messages/likeButton.vue
Normal file
110
website/client/src/components/messages/likeButton.vue
Normal 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>
|
||||||
@@ -1,73 +1,157 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card-body">
|
<div
|
||||||
<user-link
|
class="card"
|
||||||
:user-id="msg.uuid"
|
:class="{
|
||||||
:name="msg.user"
|
'system-message': isSystemMessage
|
||||||
: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 }} </span>
|
|
||||||
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span>
|
|
||||||
</p>
|
|
||||||
<div
|
<div
|
||||||
class="text markdown"
|
v-b-tooltip.hover="messageDateForSystemMessage"
|
||||||
dir="auto"
|
class="message-card"
|
||||||
v-html="parseMarkdown(msg.text)"
|
|
||||||
></div>
|
:class="{
|
||||||
<div
|
'user-sent-message': userSentMessage,
|
||||||
v-if="isMessageReported"
|
'user-received-message': !userSentMessage && !isSystemMessage,
|
||||||
class="reported"
|
'system-message': isSystemMessage
|
||||||
>
|
}"
|
||||||
<span v-once>{{ $t('reportedMessage') }}</span><br>
|
|
||||||
<span v-once>{{ $t('canDeleteNow') }}</span>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<div
|
|
||||||
v-if="msg.id"
|
|
||||||
class="d-flex"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="!isMessageReported"
|
v-if="isUserMentioned"
|
||||||
class="action d-flex align-items-center"
|
class="mentioned-icon"
|
||||||
@click="report(msg)"
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="userIsModerator && msg.flagCount"
|
||||||
|
class="message-hidden"
|
||||||
>
|
>
|
||||||
<div
|
{{ flagCountDescription }}
|
||||||
v-once
|
|
||||||
class="svg-icon"
|
|
||||||
v-html="icons.report"
|
|
||||||
></div>
|
|
||||||
<div v-once>
|
|
||||||
{{ $t('report') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="action d-flex align-items-center"
|
class="card-body"
|
||||||
@click="remove()"
|
|
||||||
>
|
>
|
||||||
|
<user-link
|
||||||
|
v-if="!isSystemMessage"
|
||||||
|
:user-id="msg.uuid"
|
||||||
|
:name="msg.user"
|
||||||
|
:backer="msg.backer"
|
||||||
|
:contributor="msg.contributor"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="!isSystemMessage"
|
||||||
|
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 }} </span>
|
||||||
|
<span v-if="msg.client && user.contributor.level >= 4">
|
||||||
|
({{ msg.client }})
|
||||||
|
</span>
|
||||||
|
</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-once
|
v-if="isSystemMessage"
|
||||||
class="svg-icon"
|
class="system-message-body"
|
||||||
v-html="icons.delete"
|
>
|
||||||
></div>
|
{{ msg.unformattedText }}
|
||||||
<div v-once>
|
|
||||||
{{ $t('delete') }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="markdownContainer"
|
||||||
|
class="text markdown"
|
||||||
|
dir="auto"
|
||||||
|
v-html="parseMarkdown(msg.text)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="isMessageReported"
|
||||||
|
class="reported"
|
||||||
|
>
|
||||||
|
<span v-once>{{ $t('reportedMessage') }}</span><br>
|
||||||
|
<span v-once>{{ $t('canDeleteNow') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<like-button
|
||||||
|
v-if="canLikeMessage"
|
||||||
|
class="mt-75"
|
||||||
|
:liked-by-current-user="msg.likes[user._id]"
|
||||||
|
:like-count="likeCount"
|
||||||
|
@toggle-like="like()"
|
||||||
|
/>
|
||||||
</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,43 +160,76 @@
|
|||||||
.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 {
|
.message-card:not(.system-message) {
|
||||||
cursor: pointer;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-icon {
|
.mentioned-icon {
|
||||||
color: $gray-300;
|
width: 16px;
|
||||||
margin-right: .2em;
|
height: 16px;
|
||||||
width: 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 {
|
||||||
|
color: $purple-300;
|
||||||
|
|
||||||
|
.svg-icon {
|
||||||
|
color: $purple-400;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.active {
|
.message-card {
|
||||||
color: $purple-300;
|
border-radius: 7px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 0.75rem 0.5rem 1rem;
|
||||||
|
|
||||||
.svg-icon {
|
&.system-message {
|
||||||
color: $purple-400;
|
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,49 +240,173 @@
|
|||||||
min-height: 0rem;
|
min-height: 0rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hr {
|
.card-menu {
|
||||||
margin-bottom: 0.5rem;
|
position: absolute;
|
||||||
margin-top: 0.5rem;
|
top: 0;
|
||||||
}
|
right: 0;
|
||||||
|
|
||||||
.reported {
|
&:not(.show) {
|
||||||
margin-top: 18px;
|
display: none;
|
||||||
color: $red-50;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body:hover {
|
||||||
|
.card-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported {
|
||||||
|
margin-top: 18px;
|
||||||
|
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);
|
||||||
|
|
||||||
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
|
if (this.privateMessageMode) {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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
|
<message-card
|
||||||
class="card"
|
:msg="msg"
|
||||||
:class="{'card-right': user._id !== msg.uuid, 'card-left': user._id === msg.uuid}"
|
:user-sent-message="user._id === msg.uuid"
|
||||||
>
|
:group-id="'privateMessage'"
|
||||||
<message-card
|
:private-message-mode="true"
|
||||||
:msg="msg"
|
@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>
|
||||||
@@ -69,121 +72,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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;
|
::v-deep .character-sprites {
|
||||||
padding-top: 0 !important;
|
margin-bottom: -5px !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
margin-top: -1px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-right {
|
::v-deep .avatar {
|
||||||
margin-left: -1rem;
|
margin-left: -1.75rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::v-deep .character-sprites {
|
.card {
|
||||||
margin-right: 1rem !important;
|
border: 0px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0rem;
|
||||||
|
width: 684px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 10px;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
margin-left: 12px;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
&:not(.margin-right) {
|
||||||
|
.d-flex {
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.hr {
|
||||||
border: 0px;
|
width: 100%;
|
||||||
margin-bottom: 1rem;
|
height: 20px;
|
||||||
padding: 0rem;
|
border-bottom: 1px solid $gray-500;
|
||||||
width: 684px;
|
text-align: center;
|
||||||
}
|
margin: 2em 0;
|
||||||
.message-row {
|
}
|
||||||
margin-left: 12px;
|
|
||||||
margin-right: 12px;
|
|
||||||
|
|
||||||
&:not(.margin-right) {
|
.hr-middle {
|
||||||
.d-flex {
|
font-size: 16px;
|
||||||
justify-content: flex-end;
|
font-weight: bold;
|
||||||
}
|
font-family: 'Roboto Condensed';
|
||||||
}
|
line-height: 1.5;
|
||||||
}
|
text-align: center;
|
||||||
@media only screen and (max-width: 1200px) {
|
color: $gray-200;
|
||||||
.card {
|
background-color: $gray-700;
|
||||||
width: 100%;
|
padding: .2em;
|
||||||
}
|
margin-top: .2em;
|
||||||
}
|
display: inline-block;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (min-width: 1400px) {
|
.loadmore {
|
||||||
.message-row {
|
justify-content: center;
|
||||||
margin-left: -15px;
|
margin-right: 12px;
|
||||||
margin-right: -30px;
|
margin-top: 12px;
|
||||||
}
|
margin-bottom: 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.card-left {
|
> div {
|
||||||
border: 1px solid $purple-500;
|
display: flex;
|
||||||
}
|
|
||||||
|
|
||||||
.card-right {
|
|
||||||
border: 1px solid $gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 20px;
|
align-items: center;
|
||||||
border-bottom: 1px solid $gray-500;
|
|
||||||
text-align: center;
|
|
||||||
margin: 2em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hr-middle {
|
button {
|
||||||
font-size: 16px;
|
text-align: center;
|
||||||
font-weight: bold;
|
color: $gray-50;
|
||||||
font-family: 'Roboto Condensed';
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: center;
|
|
||||||
color: $gray-200;
|
|
||||||
background-color: $gray-700;
|
|
||||||
padding: .2em;
|
|
||||||
margin-top: .2em;
|
|
||||||
display: inline-block;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadmore {
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
button {
|
|
||||||
text-align: center;
|
|
||||||
color: $gray-50;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loadmore-divider-holder {
|
.loadmore-divider-holder {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
margin-right: 24px;
|
margin-right: 24px;
|
||||||
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loadmore-divider {
|
.loadmore-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
border-top: 1px $gray-500 solid;
|
border-top: 1px $gray-500 solid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref="textInput"
|
||||||
:value="value"
|
:value="value"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:type="inputType"
|
:type="inputType"
|
||||||
@@ -29,19 +30,23 @@
|
|||||||
}"
|
}"
|
||||||
: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>
|
||||||
<div
|
<template v-if="!hideErrorLine">
|
||||||
v-for="issue in invalidIssues"
|
<div
|
||||||
:key="issue"
|
v-for="issue in invalidIssues"
|
||||||
class="input-error"
|
:key="issue"
|
||||||
>
|
class="input-error"
|
||||||
{{ issue }}
|
>
|
||||||
</div>
|
{{ issue }}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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: 14px;
|
||||||
font-size: 16px;
|
line-height: 1.71;
|
||||||
|
display: inline-flex !important;
|
||||||
// currently used in the member-details-new.vue
|
|
||||||
&.smaller {
|
|
||||||
font-family: Roboto;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1.71;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.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)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
102
website/client/src/mixins/autoCompleteHelper.js
Normal file
102
website/client/src/mixins/autoCompleteHelper.js
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
71
website/client/src/pages/private-messages/pm-empty-state.vue
Normal file
71
website/client/src/pages/private-messages/pm-empty-state.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
44
website/client/src/pages/private-messages/privateMessages.d.ts
vendored
Normal file
44
website/client/src/pages/private-messages/privateMessages.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -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 Habitica’s 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 Habitica’s 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>."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
website/server/models/typedefs.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user