diff --git a/test/api/unit/libs/highlightMentions.test.js b/test/api/unit/libs/highlightMentions.test.js index 30fb34c72c..266bfbaaa6 100644 --- a/test/api/unit/libs/highlightMentions.test.js +++ b/test/api/unit/libs/highlightMentions.test.js @@ -47,6 +47,12 @@ describe('highlightMentions', () => { expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)'); }); + it('highlights users with case-insensitive matching', async () => { + const text = '@USER: message @User2 @USER3'; + const result = await highlightMentions(text); + expect(result[0]).to.equal('[@USER](/profile/111): message [@User2](/profile/222) [@USER3](/profile/333)'); + }); + it('doesn\'t highlight nonexisting users', async () => { const text = '@nouser message'; const result = await highlightMentions(text); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index ba4ac00ed0..f16cbea6f9 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -238,6 +238,18 @@ describe('POST /chat', () => { expect(groupMessages[0].id).to.exist; }); + it('creates a chat with case-insensitive mentions', async () => { + const originalUsername = member.auth.local.username; + const uppercaseUsername = originalUsername.toUpperCase(); + const messageWithMentions = `hi @${uppercaseUsername}`; + const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: messageWithMentions }); + const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`); + + expect(newMessage.message.id).to.exist; + expect(newMessage.message.text).to.include(`[@${uppercaseUsername}](/profile/${member._id})`); + expect(groupMessages[0].id).to.exist; + }); + it('creates a chat with a max length of 3000 chars', async () => { const veryLongMessage = ` 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789. diff --git a/test/api/v3/integration/content/GET-content.test.js b/test/api/v3/integration/content/GET-content.test.js index 0dbd2779e4..439667be9f 100644 --- a/test/api/v3/integration/content/GET-content.test.js +++ b/test/api/v3/integration/content/GET-content.test.js @@ -1,6 +1,7 @@ import { requester, translate as t, + generateUser, } from '../../../../helpers/api-integration/v3'; import i18n from '../../../../../website/common/script/i18n'; @@ -56,4 +57,28 @@ describe('GET /content', () => { const res = await requester().get('/content?filter=backgroundsFlat,invalid'); expect(res).to.not.have.property('backgroundsFlat'); }); + + describe('authenticated user', () => { + let user; + it('returns content in user\'s preferred language when no language parameter is provided', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de')); + }); + + it('respects language parameter over user\'s preferred language', async () => { + user = await generateUser({ 'preferences.language': 'de' }); + const res = await user.get('/content?language=fr'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'fr')); + }); + + it('falls back to English if user\'s preferred language is invalid', async () => { + user = await generateUser({ 'preferences.language': 'invalid_lang' }); + const res = await user.get('/content'); + expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach'); + expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText')); + }); + }); }); diff --git a/website/client/src/components/achievements/onboardingComplete.vue b/website/client/src/components/achievements/onboardingComplete.vue index b7fb595191..6b8ccedaf4 100644 --- a/website/client/src/components/achievements/onboardingComplete.vue +++ b/website/client/src/components/achievements/onboardingComplete.vue @@ -117,7 +117,7 @@ export default { closeWithAction () { this.close(); setTimeout(() => { - this.$router.push({ name: 'achievements' }); + this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`); }, 200); }, }, diff --git a/website/client/src/components/challenges/challengeDetail.vue b/website/client/src/components/challenges/challengeDetail.vue index f6d146249f..c9a4216532 100644 --- a/website/client/src/components/challenges/challengeDetail.vue +++ b/website/client/src/components/challenges/challengeDetail.vue @@ -75,16 +75,20 @@ class="box member-count" @click="showMemberModal()" > -
- {{ challenge.memberCount }} -
- {{ $t('participantsTitle') }} +
+
+
+ {{ challenge.memberCount }} +
+
+ {{ $t('participantsTitle') }} +
@@ -304,7 +308,7 @@ .box { display: inline-block; - padding: 1em; + padding: 0.5em; border-radius: 2px; background-color: $white; box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); @@ -314,22 +318,69 @@ text-align: center; font-size: 20px; vertical-align: bottom; + overflow: hidden; + position: relative; &.member-count:hover { cursor: pointer; } + .box-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } + + .icon-number-row { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.1em; + + .number { + font-size: 20px; + font-weight: normal; + margin-left: 0.2em; + } + } + .svg-icon { width: 30px; display: inline-block; - margin-right: .2em; vertical-align: bottom; } .details { font-size: 12px; - margin-top: 0.4em; color: $gray-200; + width: 100%; + padding: 0 4px; + line-height: 1.15; + word-break: break-word; + max-height: 2.3em; + overflow: visible; + } + + &.member-count { + .icon-number-row { + .svg-icon { + width: 24px; + height: 24px; + } + + .number { + font-size: 18px; + } + } + + .details { + font-size: 11px; + line-height: 1.1; + max-height: 2.2em; + } } } diff --git a/website/client/src/components/challenges/closeChallengeModal.vue b/website/client/src/components/challenges/closeChallengeModal.vue index 42caa3b457..b59a54637e 100644 --- a/website/client/src/components/challenges/closeChallengeModal.vue +++ b/website/client/src/components/challenges/closeChallengeModal.vue @@ -4,7 +4,7 @@ id="close-challenge-modal" :title="$t('endChallenge')" size="md" - :hide-header="true" + :hide-header="false" >
{{ $t('awardWinners') }} @@ -97,7 +98,7 @@ class="gem-icon" v-html="icons.gem" >
- {{ prize }} {{ prize === 1 ? $t('gem') || 'Gem' : $t('gems') }} + {{ prize }} {{ prize === 1 ? $t('gem') : $t('gems') }}
@@ -266,6 +267,7 @@ width: 16px; height: 16px; display: inline-flex; + color: $white; } } @@ -280,6 +282,15 @@ box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); transition: all 0.2s ease; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background-color: $white; + } + } + &.has-winner { background-color: $purple-200; color: $white; @@ -290,7 +301,7 @@ } } - &:hover:not(.has-winner) { + &:hover:not(.has-winner):not(:disabled) { background-color: $gray-700; } @@ -391,13 +402,13 @@ export default { this.filteredMembers = []; return; } - + const searchLower = this.searchTerm.toLowerCase().replace('@', ''); this.filteredMembers = this.members.filter(member => { const username = member.auth?.local?.username || ''; const displayName = member.profile?.name || ''; - return username.toLowerCase().includes(searchLower) || - displayName.toLowerCase().includes(searchLower); + return username.toLowerCase().includes(searchLower) + || displayName.toLowerCase().includes(searchLower); }).slice(0, 10); }, getMemberDisplayName (member) { diff --git a/website/client/src/components/header/notifications/g1g1.vue b/website/client/src/components/header/notifications/g1g1.vue index 1edf280019..ec684d9d41 100644 --- a/website/client/src/components/header/notifications/g1g1.vue +++ b/website/client/src/components/header/notifications/g1g1.vue @@ -70,13 +70,19 @@ } .btn-secondary { - width: 5.75rem; + min-width: 5.75rem; + width: auto; + max-width: calc(100% - 2rem); min-height: 1.5rem; + padding: 0.25rem 0.75rem; border-radius: 2px; border-color: $white; box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); font-size: 12px; font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/website/client/src/components/header/notifications/onboardingComplete.vue b/website/client/src/components/header/notifications/onboardingComplete.vue index 9ad68ba316..b30dacf152 100644 --- a/website/client/src/components/header/notifications/onboardingComplete.vue +++ b/website/client/src/components/header/notifications/onboardingComplete.vue @@ -71,7 +71,7 @@ export default { props: ['notification', 'canRemove'], methods: { action () { - this.$router.push({ name: 'achievements' }); + this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`); }, }, }; diff --git a/website/client/src/components/header/notifications/unallocatedStatsPoints.vue b/website/client/src/components/header/notifications/unallocatedStatsPoints.vue index 1fd8964fff..b15dc35c1c 100644 --- a/website/client/src/components/header/notifications/unallocatedStatsPoints.vue +++ b/website/client/src/components/header/notifications/unallocatedStatsPoints.vue @@ -43,7 +43,7 @@ export default { }, methods: { action () { - this.$router.push({ name: 'stats' }); + this.$router.push(`/profile/${this.$store.state.user.data._id}#stats`); }, }, }; diff --git a/website/client/src/components/header/userDropdown.vue b/website/client/src/components/header/userDropdown.vue index efaec0a534..ea565996f3 100644 --- a/website/client/src/components/header/userDropdown.vue +++ b/website/client/src/components/header/userDropdown.vue @@ -176,7 +176,12 @@ export default { } }, showProfile (startingPage) { - this.$router.push({ name: startingPage }); + const userId = this.$store.state.user.data._id; + let path = `/profile/${userId}`; + if (startingPage !== 'profile') { + path += `#${startingPage}`; + } + this.$router.push(path); }, toLearnMore () { this.$router.push({ name: 'subscription' }); diff --git a/website/client/src/components/userMenu/profile.vue b/website/client/src/components/userMenu/profile.vue index c85e69f968..d42713d4a0 100644 --- a/website/client/src/components/userMenu/profile.vue +++ b/website/client/src/components/userMenu/profile.vue @@ -1126,7 +1126,12 @@ export default { this.loadUser(); this.oldTitle = this.$store.state.title; this.handleExternalLinks(); - this.selectPage(this.startingPage); + // Check if there's a hash in the URL to determine the starting page + let pageToSelect = this.startingPage; + if (window.location.hash && (window.location.hash === '#stats' || window.location.hash === '#achievements')) { + pageToSelect = window.location.hash.substring(1); + } + this.selectPage(pageToSelect); this.$root.$on('habitica:report-profile-result', () => { this.loadUser(); }); @@ -1211,10 +1216,15 @@ export default { }, selectPage (page) { this.selectedPage = page || 'profile'; - window.history.replaceState(null, null, ''); + const profileUserId = this.userId || this.userLoggedIn._id; + let newPath = `/profile/${profileUserId}`; + if (page !== 'profile') { + newPath += `#${page}`; + } + window.history.replaceState(null, null, newPath); this.$store.dispatch('common:setTitle', { section: this.$t('user'), - subSection: this.$t(this.startingPage), + subSection: this.$t(page), }); }, getNextIncentive () { diff --git a/website/client/src/router/index.js b/website/client/src/router/index.js index 5c02b358d5..87e52aeb9e 100644 --- a/website/client/src/router/index.js +++ b/website/client/src/router/index.js @@ -98,6 +98,9 @@ const router = new VueRouter({ path: '/profile/:userId', props: true, }, + { name: 'profile', path: '/user/profile' }, + { name: 'stats', path: '/user/stats' }, + { name: 'achievements', path: '/user/achievements' }, { path: '/inventory', component: InventoryContainer, @@ -332,6 +335,10 @@ router.beforeEach(async (to, from, next) => { if (to.params.startingPage !== undefined) { startingPage = to.params.startingPage; } + // Check if there's a hash in the URL for stats or achievements + if (to.hash === '#stats' || to.hash === '#achievements') { + startingPage = to.hash.substring(1); + } if (from.name === null) { store.state.postLoadModal = `profile/${to.params.userId}`; return next({ name: 'tasks' }); @@ -352,10 +359,18 @@ router.beforeEach(async (to, from, next) => { } if ((to.name === 'stats' || to.name === 'achievements' || to.name === 'profile') && from.name !== null) { + const userId = store.state.user.data._id; + let redirectPath = `/profile/${userId}`; + if (to.name === 'stats') { + redirectPath += '#stats'; + } else if (to.name === 'achievements') { + redirectPath += '#achievements'; + } router.app.$emit('habitica:show-profile', { + userId, startingPage: to.name, fromPath: from.path, - toPath: to.path, + toPath: redirectPath, }); return null; } diff --git a/website/server/controllers/api-v3/content.js b/website/server/controllers/api-v3/content.js index ed8a79d782..e48b79fb1c 100644 --- a/website/server/controllers/api-v3/content.js +++ b/website/server/controllers/api-v3/content.js @@ -1,6 +1,7 @@ import nconf from 'nconf'; import { langCodes } from '../../libs/i18n'; import { serveContent } from '../../libs/content'; +import { authWithHeaders } from '../../middlewares/auth'; const IS_PROD = nconf.get('IS_PROD'); @@ -66,12 +67,21 @@ api.getContent = { method: 'GET', url: '/content', noLanguage: true, + middlewares: [authWithHeaders({ optional: true })], async handler (req, res) { let language = 'en'; const proposedLang = req.query.language; if (proposedLang && langCodes.includes(proposedLang)) { language = proposedLang; + } else if (res.locals.user + && res.locals.user.preferences + && res.locals.user.preferences.language + ) { + const userLang = res.locals.user.preferences.language; + if (langCodes.includes(userLang)) { + language = userLang; + } } let filter = req.query.filter || ''; diff --git a/website/server/libs/highlightMentions.js b/website/server/libs/highlightMentions.js index ccb074590e..ae2ca5d293 100644 --- a/website/server/libs/highlightMentions.js +++ b/website/server/libs/highlightMentions.js @@ -164,18 +164,24 @@ export default async function highlightMentions (text) { if (mentions && mentions.length <= 5) { const usernames = mentions.map(mention => mention.substr(1)); + const usernameRegexes = usernames.map(username => new RegExp(`^${escapeRegExp(username)}$`, 'i')); members = await User - .find({ 'auth.local.username': { $in: usernames }, 'flags.verifiedUsername': true }) + .find({ + $or: usernameRegexes.map(regex => ({ 'auth.local.username': regex })), + 'flags.verifiedUsername': true, + }) .select(['auth.local.username', '_id', 'preferences.pushNotifications', 'pushDevices', 'party', 'guilds']) .lean() .exec(); const baseUrl = determineBaseUrl(); members.forEach(member => { const { username } = member.auth.local; - const regex = new RegExp(`@${username}(?![\\-\\w])`, 'g'); - const replacement = `[@${username}](${baseUrl}/profile/${member._id})`; + const regex = new RegExp(`@${escapeRegExp(username)}(?![\\-\\w])`, 'gi'); - textBlocks.transformValidBlocks(blockText => blockText.replace(regex, replacement)); + textBlocks.transformValidBlocks(blockText => blockText.replace(regex, match => { + const mentionedUsername = match.substr(1); + return `[@${mentionedUsername}](${baseUrl}/profile/${member._id})`; + })); }); }