diff --git a/.gitignore b/.gitignore index f1d956d2e2..306a751683 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ dist-client test/client/unit/coverage test/client/e2e/reports test/client-old/spec/mocks/translations.js +yarn.lock # Elastic Beanstalk Files .elasticbeanstalk/* diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index c70b41806f..0176a729ff 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -304,14 +304,14 @@ describe('POST /challenges', () => { expect(groupLeader.challenges.length).to.equal(0); }); - it('awards achievement if this is creator\'s first challenge', async () => { + it('does not award joinedChallenge achievement for creating a challenge', async () => { await groupLeader.post('/challenges', { group: group._id, name: 'Test Challenge', shortName: 'TC Label', }); groupLeader = await groupLeader.sync(); - expect(groupLeader.achievements.joinedChallenge).to.be.true; + expect(groupLeader.achievements.joinedChallenge).to.not.be.true; }); it('sets summary to challenges name when not supplied', async () => { diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index d2f321c1fa..cbbe45b546 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -77,7 +77,7 @@ describe('GET /groups/:groupId/members', () => { expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', - 'chair', 'costume', 'sleep', 'background', 'tasks', + 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', ].sort()); expect(memberRes.stats.maxMP).to.exist; @@ -98,7 +98,7 @@ describe('GET /groups/:groupId/members', () => { expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', - 'chair', 'costume', 'sleep', 'background', 'tasks', + 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', ].sort()); expect(memberRes.stats.maxMP).to.exist; diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index 7a4f23fb80..1dca0284b9 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -37,7 +37,7 @@ describe('GET /members/:memberId', () => { expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', - 'chair', 'costume', 'sleep', 'background', 'tasks', + 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', ].sort()); expect(memberRes.stats.maxMP).to.exist; diff --git a/test/api/v3/integration/notifications/prevent-multiple-notification.js b/test/api/v3/integration/notifications/prevent-multiple-notification.js new file mode 100644 index 0000000000..6fcf655d55 --- /dev/null +++ b/test/api/v3/integration/notifications/prevent-multiple-notification.js @@ -0,0 +1,39 @@ +import { + createAndPopulateGroup, +} from '../../../../helpers/api-integration/v3'; + +describe('Prevent multiple notifications', () => { + let partyLeader, partyMembers, party; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'party', + privacy: 'private', + }, + members: 4, + }); + + party = group; + partyLeader = groupLeader; + partyMembers = members; + }); + + it('does not add the same notification twice', async () => { + const multipleChatMessages = []; + + for (let i = 0; i < 4; i++) { + for (let memberIndex = 0; memberIndex < partyMembers.length; memberIndex++) { + multipleChatMessages.push( + partyMembers[memberIndex].post(`/groups/${party._id}/chat`, { message: `Message ${i}_${memberIndex}`}), + ); + } + } + + await Promise.all(multipleChatMessages); + + const userWithNotification = await partyLeader.get('/user'); + + expect(userWithNotification.notifications.length).to.be.eq(1); + }); +}); diff --git a/website/client/components/challenges/challengeDetail.vue b/website/client/components/challenges/challengeDetail.vue index 7464cb16bc..62a429f64c 100644 --- a/website/client/components/challenges/challengeDetail.vue +++ b/website/client/components/challenges/challengeDetail.vue @@ -344,14 +344,14 @@ export default { this.tasksByType[task.type].splice(index, 1); }, showMemberModal () { - // @TODO: Change these to options and add a custom event to members modal - this.$store.state.memberModalOptions.challengeId = this.challenge._id; - this.$store.state.memberModalOptions.groupId = 'challenge'; // @TODO: change these terrible settings - this.$store.state.memberModalOptions.group = this.group; - this.$store.state.memberModalOptions.memberCount = this.challenge.memberCount; - this.$store.state.memberModalOptions.viewingMembers = this.members; - this.$store.state.memberModalOptions.fetchMoreMembers = this.loadMembers; - this.$root.$emit('bv::show::modal', 'members-modal'); + this.$root.$emit('habitica:show-member-modal', { + challengeId: this.challenge._id, + groupId: 'challenge', // @TODO: change these terrible settings + group: this.group, + memberCount: this.challenge.memberCount, + viewingMembers: this.members, + fetchMoreMembers: this.loadMembers, + }); }, async joinChallenge () { this.user.challenges.push(this.searchId); diff --git a/website/client/components/groups/group.vue b/website/client/components/groups/group.vue index 018b93e064..6f63f305b7 100644 --- a/website/client/components/groups/group.vue +++ b/website/client/components/groups/group.vue @@ -3,6 +3,7 @@ group-form-modal(v-if='isParty') start-quest-modal(:group='this.group') quest-details-modal(:group='this.group') + participant-list-modal(:group='this.group') group-gems-modal .col-12.col-sm-8.standard-page .row @@ -126,7 +127,7 @@ .sidebar { background-color: $gray-600; padding-bottom: 2em; - + } .buttons-wrapper { @@ -262,6 +263,7 @@ import * as Analytics from 'client/libs/analytics'; import membersModal from './membersModal'; import startQuestModal from './startQuestModal'; import questDetailsModal from './questDetailsModal'; +import participantListModal from './participantListModal'; import groupFormModal from './groupFormModal'; import groupChallenges from '../challenges/groupChallenges'; import groupGemsModal from 'client/components/groups/groupGemsModal'; @@ -292,6 +294,7 @@ export default { groupFormModal, groupChallenges, questDetailsModal, + participantListModal, groupGemsModal, questSidebarSection, sidebarSection, @@ -426,12 +429,13 @@ export default { return this.$store.dispatch('members:getGroupMembers', payload); }, showMemberModal () { - this.$store.state.memberModalOptions.groupId = this.group._id; - this.$store.state.memberModalOptions.group = this.group; - this.$store.state.memberModalOptions.memberCount = this.group.memberCount; - this.$store.state.memberModalOptions.viewingMembers = this.members; - this.$store.state.memberModalOptions.fetchMoreMembers = this.loadMembers; - this.$root.$emit('bv::show::modal', 'members-modal'); + this.$root.$emit('habitica:show-member-modal', { + groupId: this.group._id, + group: this.group, + memberCount: this.group.memberCount, + viewingMembers: this.members, + fetchMoreMembers: this.loadMembers, + }); }, fetchRecentMessages () { this.fetchGuild(); @@ -538,24 +542,6 @@ export default { this.$store.state.upgradingGroup = this.group; this.$router.push('/group-plans'); }, - clickStartQuest () { - Analytics.track({ - hitType: 'event', - eventCategory: 'button', - eventAction: 'click', - eventLabel: 'Start a Quest', - }); - - let hasQuests = find(this.user.items.quests, (quest) => { - return quest > 0; - }); - - if (hasQuests) { - this.$root.$emit('bv::show::modal', 'start-quest-modal'); - return; - } - // $rootScope.$state.go('options.inventory.quests'); - }, showGroupGems () { this.$root.$emit('bv::show::modal', 'group-gems-modal'); }, diff --git a/website/client/components/groups/membersModal.vue b/website/client/components/groups/membersModal.vue index 1ce8bc98af..011a9ec95b 100644 --- a/website/client/components/groups/membersModal.vue +++ b/website/client/components/groups/membersModal.vue @@ -278,7 +278,20 @@ export default { }; }, mounted () { - this.getMembers(); + this.$root.$on('habitica:show-member-modal', (data) => { + // @TODO: Remove store + this.$store.state.memberModalOptions.challengeId = data.challengeId; + this.$store.state.memberModalOptions.groupId = data.groupId; + this.$store.state.memberModalOptions.group = data.group; + this.$store.state.memberModalOptions.memberCount = data.memberCount; + this.$store.state.memberModalOptions.viewingMembers = data.viewingMembers; + this.$store.state.memberModalOptions.fetchMoreMembers = data.fetchMoreMembers; + this.$root.$emit('bv::show::modal', 'members-modal'); + this.getMembers(); + }); + }, + destroyed () { + this.$root.$off('habitica:show-member-modal'); }, computed: { ...mapState({user: 'user.data'}), @@ -363,6 +376,7 @@ export default { }); this.invites = invites; } + if (this.$store.state.memberModalOptions.viewingMembers.length > 0) { this.members = this.$store.state.memberModalOptions.viewingMembers; } diff --git a/website/client/components/groups/participantListModal.vue b/website/client/components/groups/participantListModal.vue new file mode 100644 index 0000000000..1495730bb1 --- /dev/null +++ b/website/client/components/groups/participantListModal.vue @@ -0,0 +1,93 @@ + + + + + + + \ No newline at end of file diff --git a/website/client/components/groups/questSidebarSection.vue b/website/client/components/groups/questSidebarSection.vue index 45b5fbc212..8c09ee5c94 100644 --- a/website/client/components/groups/questSidebarSection.vue +++ b/website/client/components/groups/questSidebarSection.vue @@ -24,6 +24,9 @@ sidebar-section(:title="$t('questDetailsTitle')") h3(v-once) {{ questData.text() }} .quest-box .collect-info(v-if='questData.collect') + .row + .col-12 + a.float-right(@click="openParticipantList()") {{ $t('participantsTitle') }} .row(v-for='(value, key) in questData.collect') .col-2 div(:class="'quest_' + questData.key + '_' + key") @@ -38,7 +41,7 @@ sidebar-section(:title="$t('questDetailsTitle')") .col-6 h4.float-left(v-once) {{ questData.boss.name() }} .col-6 - span.float-right(v-once) {{ $t('participantsTitle') }} + a.float-right(@click="openParticipantList()") {{ $t('participantsTitle') }} .row .col-12 .grey-progress-bar @@ -134,6 +137,12 @@ sidebar-section(:title="$t('questDetailsTitle')") padding: .5em; margin-bottom: 1em; + a { + font-family: 'Roboto Condensed', sans-serif; + font-weight: bold; + color: $gray-10; + } + svg: { width: 100%; height: 100%; @@ -258,6 +267,9 @@ export default { openQuestDetails () { this.$root.$emit('bv::show::modal', 'quest-details'); }, + openParticipantList () { + this.$root.$emit('bv::show::modal', 'participant-list'); + }, async questAbort () { if (!confirm(this.$t('sureAbort'))) return; if (!confirm(this.$t('doubleSureAbort'))) return; diff --git a/website/client/components/groups/startQuestModal.vue b/website/client/components/groups/startQuestModal.vue index 66bee4afe1..9d13f2d0b3 100644 --- a/website/client/components/groups/startQuestModal.vue +++ b/website/client/components/groups/startQuestModal.vue @@ -148,8 +148,13 @@ export default { }; }, mounted () { - let questKeys = Object.keys(this.user.items.quests); - this.selectedQuest = questKeys[0]; + const userQuests = this.user.items.quests; + for (const key in userQuests) { + if (userQuests[key] > 0) { + this.selectedQuest = key; + break; + } + } this.$root.$on('selectQuest', this.selectQuest); }, @@ -177,13 +182,14 @@ export default { let groupId = this.group._id || this.user.party._id; const key = this.selectedQuest; - const response = await this.$store.dispatch('guilds:inviteToQuest', {groupId, key}); - const quest = response.data.data; - - if (this.$store.state.party.data) this.$store.state.party.data.quest = quest; - - this.loading = false; + try { + const response = await this.$store.dispatch('guilds:inviteToQuest', {groupId, key}); + const quest = response.data.data; + if (this.$store.state.party.data) this.$store.state.party.data.quest = quest; + } finally { + this.loading = false; + } this.$root.$emit('bv::hide::modal', 'start-quest-modal'); }, }, diff --git a/website/client/components/header/index.vue b/website/client/components/header/index.vue index 4fa0e750cf..fb7e2965c0 100644 --- a/website/client/components/header/index.vue +++ b/website/client/components/header/index.vue @@ -176,11 +176,11 @@ export default { } }, showPartyMembers () { - // Set the party details for the members-modal component - this.$store.state.memberModalOptions.groupId = this.user.party._id; - this.$store.state.memberModalOptions.viewingMembers = this.partyMembers; - this.$store.state.memberModalOptions.group = this.user.party; - this.$root.$emit('bv::show::modal', 'members-modal'); + this.$root.$emit('habitica:show-member-modal', { + groupId: this.user.party._id, + viewingMembers: this.partyMembers, + group: this.user.party, + }); }, setPartyMembersWidth ($event) { if (this.currentWidth !== $event.width) { diff --git a/website/client/components/inventory/stable/hatchingModal.vue b/website/client/components/inventory/stable/hatchingModal.vue new file mode 100644 index 0000000000..cfd89d47bc --- /dev/null +++ b/website/client/components/inventory/stable/hatchingModal.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/website/client/components/inventory/stable/index.vue b/website/client/components/inventory/stable/index.vue index 641e091b1d..8024afae6f 100644 --- a/website/client/components/inventory/stable/index.vue +++ b/website/client/components/inventory/stable/index.vue @@ -1,245 +1,205 @@ diff --git a/website/client/components/inventory/stable/welcomeModal.vue b/website/client/components/inventory/stable/welcomeModal.vue new file mode 100644 index 0000000000..178da7c3c6 --- /dev/null +++ b/website/client/components/inventory/stable/welcomeModal.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/website/client/components/tasks/column.vue b/website/client/components/tasks/column.vue index ba69531d99..4d8fa21933 100644 --- a/website/client/components/tasks/column.vue +++ b/website/client/components/tasks/column.vue @@ -37,7 +37,7 @@ draggable.sortable-tasks( ref="tasksList", @update='taskSorted', - :options='{disabled: activeFilter.label === "scheduled"}', + :options='{disabled: activeFilter.label === "scheduled", scrollSensitivity: 64}', class="sortable-tasks" ) task( diff --git a/website/client/components/tasks/taskModal.vue b/website/client/components/tasks/taskModal.vue index aa09db6fa0..c824c06c28 100644 --- a/website/client/components/tasks/taskModal.vue +++ b/website/client/components/tasks/taskModal.vue @@ -1,7 +1,7 @@ @@ -714,6 +714,9 @@ export default { }, }; }, + mounted () { + this.showAdvancedOptions = !this.user.preferences.advancedCollapsed; + }, watch: { task () { this.syncTask(); @@ -803,6 +806,12 @@ export default { return this.selectedTags.slice(this.maxTags); }, }, + created () { + document.addEventListener('keyup', this.handleEsc); + }, + destroyed () { + document.removeEventListener('keyup', this.handleEsc); + }, methods: { ...mapActions({saveTask: 'tasks:save', destroyTask: 'tasks:destroy', createTask: 'tasks:create'}), async syncTask () { @@ -988,6 +997,16 @@ export default { focusInput () { this.$refs.inputToFocus.focus(); }, + handleEsc (e) { + if (e.keyCode === 27 && this.showTagsSelect) { + this.closeTagsPopup(); + } + }, + handleClick (e) { + if (this.$refs.popup && !this.$refs.popup.$el.contains(e.target)) { + this.closeTagsPopup(); + } + }, }, }; diff --git a/website/client/mixins/petMixin.js b/website/client/mixins/petMixin.js new file mode 100644 index 0000000000..8ea3a658cf --- /dev/null +++ b/website/client/mixins/petMixin.js @@ -0,0 +1,13 @@ +export default { + methods: { + closeHatchPetDialog () { + this.$root.$emit('bv::hide::modal', 'hatching-modal'); + }, + hatchPet (pet) { + this.closeHatchPetDialog(); + + this.$store.dispatch('common:hatch', {egg: pet.eggKey, hatchingPotion: pet.potionKey}); + this.text(this.$t('hatchedPet', {egg: pet.eggName, potion: pet.potionName})); + }, + }, +}; diff --git a/website/server/libs/challenges/index.js b/website/server/libs/challenges/index.js index 9feb91b925..764cdf907b 100644 --- a/website/server/libs/challenges/index.js +++ b/website/server/libs/challenges/index.js @@ -79,8 +79,6 @@ export async function createChallenge (user, req, res) { let challengeValidationErrors = challenge.validateSync(); if (challengeValidationErrors) throw challengeValidationErrors; - addUserJoinChallengeNotification(user); - let results = await Promise.all([challenge.save({ validateBeforeSave: false, // already validated }), group.save(), user.save()]); diff --git a/website/server/models/user/index.js b/website/server/models/user/index.js index 6875d58df8..5521f21290 100644 --- a/website/server/models/user/index.js +++ b/website/server/models/user/index.js @@ -7,7 +7,7 @@ require('./methods'); // A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt - preferences.chair preferences.costume preferences.sleep preferences.background preferences.tasks profile stats + preferences.chair preferences.costume preferences.sleep preferences.background preferences.tasks preferences.disableClasses profile stats achievements party backer contributor auth.timestamps items inbox.optOut loginIncentives flags.classSelected`; // The minimum amount of data needed when populating multiple users diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 183f881d34..078ae60cab 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -7,8 +7,8 @@ import { model as Group, } from '../group'; -import { defaults, map, flatten, flow, compact, uniq, partialRight } from 'lodash'; -import { model as UserNotification } from '../userNotification'; +import {defaults, map, flatten, flow, compact, uniq, partialRight} from 'lodash'; +import {model as UserNotification} from '../userNotification'; import schema from './schema'; import payments from '../../libs/payments/payments'; import amazonPayments from '../../libs/payments/amazon'; diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index 5f0ac2c09f..15b4cec4d6 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -2,6 +2,7 @@ import mongoose from 'mongoose'; import baseModel from '../libs/baseModel'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; +import _ from 'lodash'; const NOTIFICATION_TYPES = [ 'DROPS_ENABLED', @@ -74,13 +75,22 @@ export let schema = new Schema({ schema.statics.convertNotificationsToSafeJson = function convertNotificationsToSafeJson (notifications) { if (!notifications) return notifications; - return notifications.filter(n => { + let filteredNotifications = notifications.filter(n => { // Exclude notifications with a nullish value if (!n) return false; // Exclude notifications without an id or a type if (!n.id || !n.type) return false; return true; - }).map(n => { + }); + + filteredNotifications = _.uniqWith(filteredNotifications, (val, otherVal) => { + if (val.type === otherVal.type && val.type === 'NEW_CHAT_MESSAGE') { + return val.data.group.id === otherVal.data.group.id; + } + return false; + }); + + return filteredNotifications.map(n => { return n.toJSON(); }); };