Merge branch 'sabrecat/usernames-master' into develop

This commit is contained in:
Sabe Jones
2018-11-14 08:21:49 -06:00
62 changed files with 1850 additions and 594 deletions

View File

@@ -569,7 +569,7 @@ describe('Group Model', () => {
});
it('throws an error if no uuids or emails are passed in', async () => {
await expect(Group.validateInvitations(null, null, res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
@@ -579,7 +579,7 @@ describe('Group Model', () => {
});
it('throws an error if only uuids are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations({ uuid: 'user-id'}, null, res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({ uuids: 'user-id'}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
@@ -589,7 +589,7 @@ describe('Group Model', () => {
});
it('throws an error if only emails are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations(null, { emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
@@ -599,27 +599,27 @@ describe('Group Model', () => {
});
it('throws an error if emails are not passed in, and uuid array is empty', async () => {
await expect(Group.validateInvitations([], null, res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({uuids: []}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingUuid');
expect(res.t).to.be.calledWith('inviteMustNotBeEmpty');
});
it('throws an error if uuids are not passed in, and email array is empty', async () => {
await expect(Group.validateInvitations(null, [], res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({emails: []}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingEmail');
expect(res.t).to.be.calledWith('inviteMustNotBeEmpty');
});
it('throws an error if uuids and emails are passed in as empty arrays', async () => {
await expect(Group.validateInvitations([], [], res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({emails: [], uuids: []}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
@@ -639,7 +639,7 @@ describe('Group Model', () => {
uuids.push('one-more-uuid'); // to put it over the limit
await expect(Group.validateInvitations(uuids, emails, res)).to.eventually.be.rejected.and.eql({
await expect(Group.validateInvitations({uuids, emails}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
@@ -657,33 +657,33 @@ describe('Group Model', () => {
emails.push(`user-${i}@example.com`);
}
await Group.validateInvitations(uuids, emails, res);
await Group.validateInvitations({uuids, emails}, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if only user ids are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], null, res);
await Group.validateInvitations({uuids: ['user-id', 'user-id2']}, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if only emails are passed in', async () => {
await Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res);
await Group.validateInvitations({emails: ['user1@example.com', 'user2@example.com']}, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if both uuids and emails are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res);
await Group.validateInvitations({uuids: ['user-id', 'user-id2'], emails: ['user1@example.com', 'user2@example.com']}, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if uuids are passed in and emails are an empty array', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], [], res);
await Group.validateInvitations({uuids: ['user-id', 'user-id2'], emails: []}, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if emails are passed in and uuids are an empty array', async () => {
await Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res);
await Group.validateInvitations({uuids: [], emails: ['user1@example.com', 'user2@example.com']}, res);
expect(res.t).to.not.be.called;
});
});

View File

@@ -47,6 +47,14 @@ describe('GET /challenges/:challengeId', () => {
_id: groupLeader._id,
id: groupLeader._id,
profile: {name: groupLeader.profile.name},
auth: {
local: {
username: groupLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(chal.group).to.eql({
_id: group._id,
@@ -105,6 +113,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id,
id: challengeLeader._id,
profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(chal.group).to.eql({
_id: group._id,
@@ -131,6 +147,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id,
id: challengeLeader._id,
profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
});
@@ -179,6 +203,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id,
id: challengeLeader._id,
profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(chal.group).to.eql({
_id: group._id,
@@ -205,6 +237,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id,
id: challengeLeader._id,
profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
});

View File

@@ -60,6 +60,14 @@ describe('GET /challenges/:challengeId/members', () => {
_id: groupLeader._id,
id: groupLeader._id,
profile: {name: groupLeader.profile.name},
auth: {
local: {
username: groupLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -73,8 +81,16 @@ describe('GET /challenges/:challengeId/members', () => {
_id: leader._id,
id: leader._id,
profile: {name: leader.profile.name},
auth: {
local: {
username: leader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']);
expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
@@ -88,8 +104,16 @@ describe('GET /challenges/:challengeId/members', () => {
_id: anotherUser._id,
id: anotherUser._id,
profile: {name: anotherUser.profile.name},
auth: {
local: {
username: anotherUser.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']);
expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
@@ -107,7 +131,7 @@ describe('GET /challenges/:challengeId/members', () => {
let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=not-true`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
@@ -126,7 +150,7 @@ describe('GET /challenges/:challengeId/members', () => {
let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
@@ -145,7 +169,7 @@ describe('GET /challenges/:challengeId/members', () => {
let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=true`);
expect(res.length).to.equal(32);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});

View File

@@ -81,7 +81,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => {
await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`);
expect(memberProgress).to.have.all.keys(['_id', 'id', 'profile', 'tasks']);
expect(memberProgress).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile', 'tasks']);
expect(memberProgress.profile).to.have.all.keys(['name']);
expect(memberProgress.tasks.length).to.equal(1);
});

View File

@@ -39,6 +39,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -46,6 +54,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -58,6 +74,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -65,6 +89,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -125,6 +157,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: privateGuild.leader._id,
id: privateGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -132,6 +172,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: privateGuild.leader._id,
id: privateGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
});
@@ -235,6 +283,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id,
id: party.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -242,6 +298,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id,
id: party.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -254,6 +318,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id,
id: party.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -261,6 +333,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id,
id: party.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
});
@@ -288,6 +368,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id,
id: user._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -295,6 +383,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id,
id: user._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -307,6 +403,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id,
id: user._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist;
@@ -314,6 +418,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id,
id: user._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
});

View File

@@ -40,6 +40,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(foundChallenge.group).to.eql({
_id: publicGuild._id,
@@ -62,6 +70,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(foundChallenge1.group).to.eql({
_id: publicGuild._id,
@@ -79,6 +95,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(foundChallenge2.group).to.eql({
_id: publicGuild._id,
@@ -101,6 +125,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(foundChallenge1.group).to.eql({
_id: publicGuild._id,
@@ -118,6 +150,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id,
id: publicGuild.leader._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(foundChallenge2.group).to.eql({
_id: publicGuild._id,

View File

@@ -79,6 +79,14 @@ describe('POST /challenges/:challengeId/join', () => {
_id: groupLeader._id,
id: groupLeader._id,
profile: {name: groupLeader.profile.name},
auth: {
local: {
username: groupLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(res.name).to.equal(challenge.name);
});

View File

@@ -79,6 +79,14 @@ describe('PUT /challenges/:challengeId', () => {
_id: member._id,
id: member._id,
profile: {name: member.profile.name},
auth: {
local: {
username: member.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
expect(res.name).to.equal('New Challenge Name');
expect(res.description).to.equal('New challenge description.');

View File

@@ -50,6 +50,14 @@ describe('GET /groups/:groupId/invites', () => {
_id: invited._id,
id: invited._id,
profile: {name: invited.profile.name},
auth: {
local: {
username: invited.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
@@ -58,7 +66,7 @@ describe('GET /groups/:groupId/invites', () => {
let invited = await generateUser();
await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
let res = await user.get('/groups/party/invites');
expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']);
expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
@@ -76,7 +84,7 @@ describe('GET /groups/:groupId/invites', () => {
let res = await leader.get(`/groups/${group._id}/invites`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
}).timeout(10000);

View File

@@ -56,13 +56,21 @@ describe('GET /groups/:groupId/members', () => {
_id: user._id,
id: user._id,
profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
});
});
it('populates only some fields', async () => {
await generateGroup(user, {type: 'party', name: generateUUID()});
let res = await user.get('/groups/party/members');
expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']);
expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(res[0].profile).to.have.all.keys(['name']);
});
@@ -74,7 +82,7 @@ describe('GET /groups/:groupId/members', () => {
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['timestamps']);
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',
@@ -95,7 +103,7 @@ describe('GET /groups/:groupId/members', () => {
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['timestamps']);
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',
@@ -120,7 +128,7 @@ describe('GET /groups/:groupId/members', () => {
let res = await user.get('/groups/party/members');
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});
@@ -137,7 +145,7 @@ describe('GET /groups/:groupId/members', () => {
let res = await user.get('/groups/party/members?includeAllMembers=true');
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']);
expect(member.profile).to.have.all.keys(['name']);
});
});

View File

@@ -23,6 +23,73 @@ describe('Post /groups/:groupId/invite', () => {
});
});
describe('username invites', () => {
it('returns an error when invited user is not found', async () => {
const fakeID = 'fakeuserid';
await expect(inviter.post(`/groups/${group._id}/invite`, {
usernames: [fakeID],
}))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithUsernameNotFound', {username: fakeID}),
});
});
it('returns an error when inviting yourself to a group', async () => {
await expect(inviter.post(`/groups/${group._id}/invite`, {
usernames: [inviter.auth.local.lowerCaseUsername],
}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('cannotInviteSelfToGroup'),
});
});
it('invites a user to a group by username', async () => {
const userToInvite = await generateUser();
await expect(inviter.post(`/groups/${group._id}/invite`, {
usernames: [userToInvite.auth.local.lowerCaseUsername],
})).to.eventually.deep.equal([{
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
}]);
await expect(userToInvite.get('/user'))
.to.eventually.have.nested.property('invitations.guilds[0].id', group._id);
});
it('invites multiple users to a group by uuid', async () => {
const userToInvite = await generateUser();
const userToInvite2 = await generateUser();
await expect(inviter.post(`/groups/${group._id}/invite`, {
usernames: [userToInvite.auth.local.lowerCaseUsername, userToInvite2.auth.local.lowerCaseUsername],
})).to.eventually.deep.equal([
{
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
},
{
id: group._id,
name: groupName,
inviter: inviter._id,
publicGuild: false,
},
]);
await expect(userToInvite.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id);
await expect(userToInvite2.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id);
});
});
describe('user id invites', () => {
it('returns an error when inviter has no chat privileges', async () => {
let inviterMuted = await inviter.update({'flags.chatRevoked': true});
@@ -93,7 +160,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('inviteMissingUuid'),
message: t('inviteMustNotBeEmpty'),
});
});
@@ -228,7 +295,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('inviteMissingEmail'),
message: t('inviteMustNotBeEmpty'),
});
});

View File

@@ -34,7 +34,7 @@ describe('GET /members/:memberId', () => {
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['timestamps']);
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',

View File

@@ -0,0 +1,57 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v4';
const ENDPOINT = '/user/auth/verify-display-name';
describe('POST /user/auth/verify-display-name', async () => {
let user;
beforeEach(async () => {
user = await generateUser();
});
it('successfully verifies display name including funky characters', async () => {
let newDisplayName = 'Sabé 🤬';
let response = await user.post(ENDPOINT, {
displayName: newDisplayName,
});
expect(response).to.eql({ isUsable: true });
});
context('errors', async () => {
it('errors if display name is not provided', async () => {
await expect(user.post(ENDPOINT, {
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('errors if display name is a slur', async () => {
await expect(user.post(ENDPOINT, {
displayName: 'TESTPLACEHOLDERSLURWORDHERE',
})).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueSlur')] });
});
it('errors if display name contains a slur', async () => {
await expect(user.post(ENDPOINT, {
displayName: 'TESTPLACEHOLDERSLURWORDHERE_otherword',
})).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] });
await expect(user.post(ENDPOINT, {
displayName: 'something_TESTPLACEHOLDERSLURWORDHERE',
})).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] });
await expect(user.post(ENDPOINT, {
displayName: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword',
})).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] });
});
it('errors if display name has incorrect length', async () => {
await expect(user.post(ENDPOINT, {
displayName: 'this is a very long display name over 30 characters',
})).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength')] });
});
});
});

View File

@@ -70,3 +70,9 @@
width: 366px;
height: 285px;
}
.scene_veteran_pets {
background-image: url('~assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -582px -727px;
width: 242px;
height: 62px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -20,7 +20,7 @@
@include btn-focus-hover-shadow();
}
&:hover:not(.btn-flat) {
&:hover:not(.btn-flat):not(.disabled) {
@include btn-focus-hover-shadow();
border-color: transparent;
}
@@ -37,6 +37,7 @@
.btn:disabled, .btn.disabled {
box-shadow: none;
cursor: default;
opacity: 0.64;
border-color: transparent;
}

View File

@@ -11,7 +11,12 @@
}
}
.modal {
z-index: 1350;
}
.modal-dialog {
margin: 5.5rem auto 3rem;
width: auto;
.title {

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="104" viewBox="0 0 256 104">
<defs>
<rect id="b" width="96" height="56" rx="6"/>
<filter id="a" width="112.5%" height="128.6%" x="-6.2%" y="-10.7%" filterUnits="objectBoundingBox">
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="shadowSpreadOuter1"/>
<feOffset dy="4" in="shadowSpreadOuter1" result="shadowOffsetOuter1"/>
<feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
<feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.407843137 0 0 0 0 0.384313725 0 0 0 0 0.454901961 0 0 0 0.24 0"/>
</filter>
</defs>
<g fill="none" fill-rule="evenodd">
<g opacity=".64">
<path fill="#3FDAA2" d="M194.359 74.179l3.074-1.341-2.917-1.655-1.341-3.075-1.655 2.918-3.075 1.34 2.918 1.656 1.34 3.074z" opacity=".96"/>
<path fill="#FF6165" d="M127.439 4.105l-.023 2.982 2.399-1.771 2.981.023-1.77-2.4.022-2.98-2.399 1.77-2.98-.022z" opacity=".84"/>
<path fill="#3FDAA2" d="M70.501 38.126l2.574.428-1.202-2.315.428-2.574-2.316 1.202-2.573-.427 1.202 2.315-.428 2.573z" opacity=".83"/>
<path fill="#50B5E9" d="M240.929 73.34l.173 6.333 4.962-3.939 6.334-.173-3.939-4.962-.173-6.334-4.962 3.939-6.334.173z" opacity=".73"/>
<path fill="#FFBE5D" d="M198.881 41.724l-3.984 5.397 6.708-.05 5.397 3.983-.05-6.708 3.983-5.397-6.708.051-5.397-3.984z" opacity=".82"/>
<path fill="#50B5E9" d="M81.165 96.829l-.589-4.433-3.193 3.13-4.433.59 3.13 3.193.59 4.433 3.193-3.13 4.433-.59z" opacity=".99"/>
<path fill="#50B5E9" d="M119.702 40.186l-3.901 5.91 7.068-.425 5.91 3.902-.425-7.069 3.901-5.909-7.068.425-5.909-3.902z"/>
<path fill="#FF6165" d="M162.14 91.367l3.404 2.9.278-4.463 2.9-3.404-4.463-.278-3.404-2.9-.278 4.463-2.9 3.404z" opacity=".84"/>
<path fill="#FF944C" d="M6.708 37.066l.62 3.675 2.568-2.7 3.675-.62-2.7-2.568-.62-3.675-2.568 2.7-3.675.62zM253.486 43.18l-.037-3.727-2.96 2.265-3.726.037 2.265 2.959.037 3.727 2.96-2.266 3.726-.037z" opacity=".93"/>
<path fill="#9A62FF" d="M51.481 70.952l5.13-.957-3.843-3.53-.957-5.128-3.53 3.842-5.128.957 3.843 3.53.956 5.128z" opacity=".99"/>
<path fill="#FFBE5D" d="M78.061 8.656l-.952 3.987 3.761-1.63 3.987.952-1.63-3.761.953-3.988-3.762 1.63-3.987-.952z"/>
<path fill="#3FDAA2" d="M5.863 70.74l4.692 1.207-1.849-4.478 1.208-4.692-4.478 1.849-4.692-1.208 1.849 4.478-1.208 4.692z" opacity=".96"/>
<path fill="#9A62FF" d="M182.63 14.447l5.026-1.4-4.135-3.18-1.4-5.027-3.181 4.136-5.026 1.4 4.135 3.18 1.4 5.027z" opacity=".99"/>
</g>
<g transform="translate(79 24)">
<use fill="#000" filter="url(#a)" xlink:href="#b"/>
<rect width="100" height="60" x="-2" y="-2" fill="#6133B4" stroke="#4F2A93" stroke-width="4" rx="6"/>
</g>
<path fill="#FFF" d="M99.91 39v-9.8h3.024v3.528h2.366V29.2h3.024V39H105.3v-3.584h-2.366V39H99.91zm12.138 0v-9.8h7.182v2.576h-4.186v1.12h3.878v2.352h-3.878v1.176h4.256V39h-7.252zm10.794 0v-9.8h3.024v7.14h3.738V39h-6.762zm10.178 0v-9.8h3.024v7.14h3.738V39h-6.762zm14.14.21c-2.688 0-4.634-2.03-4.634-4.97v-.252c0-2.94 1.96-4.998 4.648-4.998 2.702 0 4.648 2.03 4.648 4.97v.252c0 2.94-1.974 4.998-4.662 4.998zm.014-2.702c.98 0 1.582-.84 1.582-2.296v-.21c0-1.456-.616-2.31-1.596-2.31-.966 0-1.582.84-1.582 2.296v.21c0 1.456.616 2.31 1.596 2.31zM79 44h96v28H79z"/>
<path fill="#4E4A57" d="M102.99 64.82c.04-1.6.04-3.34-.12-4.96-.8-.18-1.76-.26-2.88-.1v5.18c0 .18-.38.28-.5.28-.24 0-.5-.14-.54-.4l-.24-14.22c0-.42.06-.76.52-.76.1 0 .72.1.72.28l.02 8.54c.74.1 2.34.06 2.98.14v-.84c0-2.74-.16-5.1-.16-7.48 0-.24.04-.64.34-.64.3 0 .74.14.78.52l.26 14.36c0 .34-.3.62-.68.62h-.1c-.14-.02-.36-.4-.4-.52zm7.64-3.18h-2.96l-.12.12-.34 2.26c-.04.3-.02 1.16-.6 1.16-.26 0-.44-.52-.44-.72 2.08-14.5 2.1-14.54 2.1-14.54.1-.38.24-.94.74-.94.22 0 .5.1.62.3l.16.42c1.16 4.58 1.7 9.28 2.24 14l.12 1.3c0 .02-.02.02-.02.04-.14.38-.22.54-.6.54h-.06c-.12 0-.32 0-.34-.14l-.5-3.8zm-1.5-10.52l-1.16 9.28 2.5.06-1.34-9.34zm4.3-.68c1.3-1.48 4.24-1.06 4.24 1.4 0 1.04-.4 1.98-1.08 2.74 2.28 1.42 3.18 3.12 3.18 5.8 0 2.86-1.58 5.3-4.72 5.3-.14 0-.54-.02-.7-.3-.22-3.08-.22-6.14-.52-9.76l-.4-4.76v-.42zm1.98 14.02c.12.02.24.02.36.02 2.12 0 2.82-2.18 2.82-4.1 0-2.22-1.02-4.92-3.64-4.92 0 3.02.22 6 .46 9zm-.82-13.58l.3 3.3c1.02 0 1.7-1.16 1.7-2.26 0-1.14-.94-1.54-2-1.04zm6.54 14.74l.54-14.84c.14-.4.24-.42.66-.42.58 0 .58.26.58.84 0 .06-.02.32-.02.36l-.6 13.68c-.04.88-1.16.96-1.16.38zm5.08-1.86l-.4-12.66c-1.24.08-1.8.06-1.84-.28.04-.68.04-.92 2.12-.8h1.68c.44.02 1.18.08 1.18.66 0 .18-.22.38-.4.38h-1.68l.38 12.7c0 .18-.4.28-.52.28s-.52-.1-.52-.28zm4.12 1.86l.54-14.84c.14-.4.24-.42.66-.42.58 0 .58.26.58.84 0 .06-.02.32-.02.36l-.6 13.68c-.04.88-1.16.96-1.16.38zm7.66-15.56c.42 0 .94.28.94.74 0 .7-1.16.56-1.44.56h-.12c-1.94 0-2.8 4.02-2.8 6.28 0 2.56.16 4.38 1.92 5.84.86.72 2.18-.02 2.48.52.4.7-.34.94-.98.94-4.54-.1-5.24-6.12-4.32-10.02.48-2.08 1.72-4.86 4.32-4.86zm7.4 11.58h-2.96l-.12.12-.34 2.26c-.04.3-.02 1.16-.6 1.16-.26 0-.44-.52-.44-.72 2.08-14.5 2.1-14.54 2.1-14.54.1-.38.24-.94.74-.94.22 0 .5.1.62.3l.16.42c1.16 4.58 1.7 9.28 2.24 14l.12 1.3c0 .02-.02.02-.02.04-.14.38-.22.54-.6.54h-.06c-.12 0-.32 0-.34-.14l-.5-3.8zm-1.5-10.52l-1.16 9.28 2.5.06-1.34-9.34zm5.02 14.24l.24-14.08c-.04-.8 1.06-1.28 1.48-.12l4.08 11.06c.02-.88.18-5.8.3-7.76.06-1.2-.02-2.42.24-3.54 0-.22.22-.22.52-.22.48.02.52.04.5.48-.06 2.08-.36 4.16-.44 6.24-.04 1.16-.24 6.7-.24 7.86-.14.42-.76.18-.92-.16-.2-.42-.36-.82-4.6-12.16l-.14 12.28c0 .6-1.02.54-1.02.12z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#A5A1AC" fill-rule="evenodd" d="M11 10c0 1.654-1.346 3-3 3s-3-1.346-3-3h6zm2-3.5a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 13 6.5zm-7 0a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 6 6.5zM14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path fill-rule="evenodd" d="M11 10c0 1.654-1.346 3-3 3s-3-1.346-3-3h6zm2-3.5a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 13 6.5zm-7 0a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 6 6.5zM14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -82,6 +82,7 @@
import hello from 'hellojs';
import { setUpAxios } from 'client/libs/auth';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import facebookSquareIcon from 'assets/svg/facebook-square.svg';
import googleIcon from 'assets/svg/google.svg';
@@ -115,13 +116,13 @@ export default {
computed: {
emailValid () {
if (this.email.length <= 3) return false;
return this.validateEmail(this.email);
return isEmail(this.email);
},
emailInvalid () {
return !this.emailValid;
},
usernameValid () {
if (this.username.length <= 3) return false;
if (this.username.length < 1) return false;
return this.usernameIssues.length === 0;
},
usernameInvalid () {
@@ -143,7 +144,7 @@ export default {
methods: {
// eslint-disable-next-line func-names
validateUsername: debounce(function (username) {
if (username.length <= 3) {
if (username.length < 1) {
return;
}
this.$store.dispatch('auth:verifyUsername', {

View File

@@ -295,6 +295,7 @@
import axios from 'axios';
import hello from 'hellojs';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import gryphon from 'assets/svg/gryphon.svg';
import habiticaIcon from 'assets/svg/habitica-logo.svg';
@@ -340,18 +341,18 @@ export default {
},
emailValid () {
if (this.email.length <= 3) return false;
return this.validateEmail(this.email);
return isEmail(this.email);
},
emailInvalid () {
if (this.email.length <= 3) return false;
return !this.emailValid;
},
usernameValid () {
if (this.username.length <= 3) return false;
if (this.username.length < 1) return false;
return this.usernameIssues.length === 0;
},
usernameInvalid () {
if (this.username.length <= 3) return false;
if (this.username.length < 1) return false;
return !this.usernameValid;
},
passwordConfirmValid () {

View File

@@ -1,26 +1,91 @@
<template lang="pug">
div.autocomplete-selection(v-if='searchResults.length > 0', :style='autocompleteStyle')
.autocomplete-results(v-for='result in searchResults', @click='select(result)') {{ result }}
.autocomplete-selection(v-if='searchResults.length > 0', :style='autocompleteStyle')
.autocomplete-results.d-flex.align-items-center(
v-for='result in searchResults',
@click='select(result)',
@mouseenter='result.hover = true',
@mouseleave='result.hover = false',
:class='{"hover-background": result.hover}',
)
span
h3.profile-name(:class='userLevelStyle(result.msg)') {{ result.displayName }}
.svg-icon(v-html="tierIcon(result.msg)", v-if='showTierStyle(result.msg)')
span.username.ml-2(v-if='result.username', :class='{"hover-foreground": result.hover}') @{{ result.username }}
</template>
<style scoped>
<style lang="scss" scoped>
@import '~client/assets/scss/tiers.scss';
@import '~client/assets/scss/colors.scss';
.autocomplete-results {
padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef;
}
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
}
.hover-foreground {
color: $purple-300 !important;
}
.profile-name {
display: inline-block;
font-size: 16px;
margin-bottom: 0rem;
}
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
.username {
color: $gray-200;
}
</style>
<script>
import groupBy from 'lodash/groupBy';
import styleHelper from 'client/mixins/styleHelper';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tier8 from 'assets/svg/tier-mod.svg';
import tier9 from 'assets/svg/tier-staff.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
export default {
props: ['selections', 'text', 'coords', 'chat', 'textbox'],
props: ['selections', 'text', 'caretPosition', 'coords', 'chat', 'textbox'],
data () {
return {
atRegex: /(?!\b)@[\w-]*$/,
currentSearch: '',
searchActive: false,
searchEscaped: false,
currentSearchPosition: 0,
tmpSelections: [],
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
};
},
computed: {
@@ -36,32 +101,46 @@ export default {
marginTop: '28px',
position: 'absolute',
minWidth: '100px',
minHeight: '100px',
zIndex: 100,
backgroundColor: 'white',
};
},
searchResults () {
if (!this.searchActive) return [];
let currentSearch = this.text.substring(this.currentSearchPosition + 1, this.text.length);
if (!this.atRegex.exec(this.text)) return [];
this.currentSearch = this.atRegex.exec(this.text)[0];
this.currentSearch = this.currentSearch.substring(1, this.currentSearch.length);
return this.tmpSelections.filter((option) => {
return option.toLowerCase().indexOf(currentSearch.toLowerCase()) !== -1;
});
return option.displayName.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1 || option.username && option.username.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1;
}).slice(0, 4);
},
},
mounted () {
this.grabUserNames();
},
created () {
document.addEventListener('keyup', this.handleEsc);
},
destroyed () {
document.removeEventListener('keyup', this.handleEsc);
},
watch: {
text (newText) {
if (!newText[newText.length - 1] || newText[newText.length - 1] === ' ') {
this.searchActive = false;
this.searchEscaped = false;
return;
}
if (newText[newText.length - 1] === '@') {
this.searchEscaped = false;
}
if (this.searchEscaped) return;
if (!this.atRegex.test(newText)) return;
if (newText[newText.length - 1] !== '@') return;
this.searchActive = true;
this.currentSearchPosition = newText.length - 1;
},
chat () {
this.resetDefaults();
@@ -72,25 +151,52 @@ export default {
resetDefaults () {
// Mounted is not called when switching between group pages because they have the
// the same parent component. So, reset the data
this.currentSearch = '';
this.searchActive = false;
this.currentSearchPosition = 0;
this.searchEscaped = false;
this.tmpSelections = [];
},
grabUserNames () {
let usersThatMessage = groupBy(this.chat, 'user');
for (let userName in usersThatMessage) {
let systemMessage = userName === 'undefined';
if (!systemMessage && this.tmpSelections.indexOf(userName) === -1) {
this.tmpSelections.push(userName);
for (let userKey in usersThatMessage) {
let systemMessage = userKey === 'undefined';
if (!systemMessage && this.tmpSelections.indexOf(userKey) === -1) {
this.tmpSelections.push({
displayName: userKey,
username: usersThatMessage[userKey][0].username,
msg: {
backer: usersThatMessage[userKey][0].backer,
contributor: usersThatMessage[userKey][0].contributor,
},
hover: false,
});
}
}
},
showTierStyle (message) {
const isContributor = Boolean(message.contributor && message.contributor.level);
const isNPC = Boolean(message.backer && message.backer.npc);
return isContributor || isNPC;
},
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${message.contributor.level}`];
},
select (result) {
let newText = this.text.slice(0, this.currentSearchPosition + 1) + result;
this.searchActive = false;
let newText = this.text;
const targetName = `${result.username || result.displayName} `;
newText = newText.replace(new RegExp(`${this.currentSearch}$`), targetName);
this.$emit('select', newText);
},
handleEsc (e) {
if (e.keyCode === 27) {
this.searchActive = false;
this.searchEscaped = true;
}
},
},
mixins: [styleHelper],
};
</script>

View File

@@ -4,37 +4,50 @@ div
.message-hidden(v-if='msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden
.message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden
.card-body
h3.leader(
:class='userLevelStyle(msg)',
@click="showMemberModal(msg.uuid)",
v-b-tooltip.hover.top="tierTitle",
)
| {{msg.user}}
.svg-icon(v-html="tierIcon", v-if='showShowTierStyle')
p.time(v-b-tooltip="", :title="msg.timestamp | date") {{msg.timestamp | timeAgo}}
.text(v-markdown='msg.text')
hr
div(v-if='msg.id')
.action(@click='like()', v-if='!inbox && msg.likes', :class='{active: msg.likes[user._id]}')
.svg-icon(v-html="icons.like")
h3.leader(
:class='userLevelStyle(msg)',
@click="showMemberModal(msg.uuid)",
v-b-tooltip.hover.top="tierTitle",
v-if="msg.user"
)
| {{msg.user}}
.svg-icon(v-html="tierIcon")
p.time
span.mr-1(v-if="msg.username") @{{ msg.username }}
span.mr-1(v-if="msg.username")
span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }}
.text(v-html='atHighlight(parseMarkdown(msg.text))')
hr
.d-flex(v-if='msg.id')
.action.d-flex.align-items-center(v-if='!inbox', @click='copyAsTodo(msg)')
.svg-icon(v-html="icons.copy")
div {{$t('copyAsTodo')}}
.action.d-flex.align-items-center(v-if='!inbox && user.flags.communityGuidelinesAccepted && msg.uuid !== "system"', @click='report(msg)')
.svg-icon(v-html="icons.report")
div {{$t('report')}}
// @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys
.action.d-flex.align-items-center(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove()')
.svg-icon(v-html="icons.delete")
| {{$t('delete')}}
.ml-auto.d-flex(v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}", v-if='!inbox')
.action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount > 0', :class='{active: msg.likes[user._id]}')
.svg-icon(v-html="icons.liked", :title='$t("liked")')
| +{{ likeCount }}
.action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount === 0', :class='{active: msg.likes[user._id]}')
.svg-icon(v-html="icons.like", :title='$t("like")')
span(v-if='!msg.likes[user._id]') {{ $t('like') }}
span(v-if='msg.likes[user._id]') {{ $t('liked') }}
span.action(v-if='!inbox', @click='copyAsTodo(msg)')
.svg-icon(v-html="icons.copy")
| {{$t('copyAsTodo')}}
span.action(v-if='!inbox && user.flags.communityGuidelinesAccepted && msg.uuid !== "system"', @click='report(msg)')
.svg-icon(v-html="icons.report")
| {{$t('report')}}
// @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys
span.action(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove()')
.svg-icon(v-html="icons.delete")
| {{$t('delete')}}
span.action.float-right.liked(v-if='likeCount > 0')
.svg-icon(v-html="icons.liked")
| + {{ likeCount }}
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
color: #6133b4;
padding: 0.1rem;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss';
.mentioned-icon {
@@ -54,7 +67,14 @@ div
color: red;
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.leader {
margin-bottom: 0;
}
@@ -62,6 +82,7 @@ div
h3 { // this is the user name
cursor: pointer;
display: inline-block;
font-size: 16px;
.svg-icon {
width: 10px;
@@ -73,13 +94,15 @@ div
.time {
font-size: 12px;
color: #878190;
width: 150px;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: #4e4a57;
text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
}
}
@@ -87,25 +110,25 @@ div
display: inline-block;
color: #878190;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: #A5A1AC;
margin-right: .2em;
width: 16px;
}
}
.action:hover {
cursor: pointer;
}
.active {
color: $purple-300;
.liked:hover {
cursor: default;
}
.action .svg-icon {
margin-right: .2em;
width: 16px;
display: inline-block;
color: #A5A1AC;
}
.action.active, .active .svg-icon {
color: #46a7d9
.svg-icon {
color: $purple-400;
}
}
</style>
@@ -114,8 +137,9 @@ import axios from 'axios';
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import max from 'lodash/max';
import markdownDirective from 'client/directives/markdown';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from 'client/libs/store';
import styleHelper from 'client/mixins/styleHelper';
@@ -161,9 +185,6 @@ export default {
}),
};
},
directives: {
markdown: markdownDirective,
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
@@ -177,23 +198,24 @@ export default {
...mapState({user: 'user.data'}),
isUserMentioned () {
const message = this.msg;
let user = this.user;
const user = this.user;
if (message.hasOwnProperty('highlight')) return message.highlight;
message.highlight = false;
let messagetext = message.text.toLowerCase();
let username = user.profile.name;
let mentioned = messagetext.indexOf(username.toLowerCase());
let escapedUsername = escapeRegExp(username);
let pattern = `@${escapedUsername}([^\w]|$){1}`;
const messageText = message.text.toLowerCase();
const displayName = user.profile.name;
const username = user.auth.local && user.auth.local.username;
const mentioned = max([messageText.indexOf(username.toLowerCase()), messageText.indexOf(displayName.toLowerCase())]);
if (mentioned === -1) return message.highlight;
let preceedingchar = messagetext.substring(mentioned - 1, mentioned);
if (mentioned === 0 || preceedingchar.trim() === '' || preceedingchar === '@') {
const escapedDisplayName = escapeRegExp(displayName);
const escapedUsername = escapeRegExp(username);
const pattern = `@(${escapedUsername}|${escapedDisplayName})([^\w]|$)`;
const precedingChar = messageText.substring(mentioned - 1, mentioned);
if (mentioned === 0 || precedingChar.trim() === '' || precedingChar === '@') {
let regex = new RegExp(pattern, 'i');
message.highlight = regex.test(messagetext);
message.highlight = regex.test(messageText);
}
return message.highlight;
@@ -209,12 +231,6 @@ export default {
}
return likeCount;
},
showShowTierStyle () {
const message = this.msg;
const isContributor = Boolean(message.contributor && message.contributor.level);
const isNPC = Boolean(message.backer && message.backer.npc);
return isContributor || isNPC;
},
tierIcon () {
const message = this.msg;
const isNPC = Boolean(message.backer && message.backer.npc);
@@ -244,6 +260,10 @@ export default {
}
this.$emit('message-liked', message);
this.$root.$emit('bv::hide::tooltip');
},
likeTooltip (likedStatus) {
if (!likedStatus) return this.$t('like');
},
copyAsTodo (message) {
this.$root.$emit('habitica::copy-as-todo', message);
@@ -273,6 +293,14 @@ export default {
showMemberModal (memberId) {
this.$emit('show-member-modal', memberId);
},
atHighlight (text) {
return text.replace(new RegExp(/(?!\b)@[\w-]+/g), match => {
return `<span class="at-highlight">${match}</span>`;
});
},
parseMarkdown (text) {
return habiticaMarkdown.render(text);
},
},
};
</script>

View File

@@ -14,6 +14,7 @@
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
:member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true",
:overrideTopPadding='"14px"',
:hideClassBadge='true',
@click.native="showMemberModal(msg.uuid)",
)
@@ -40,6 +41,7 @@
:member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true",
:hideClassBadge='true',
:overrideTopPadding='"14px"',
@click.native="showMemberModal(msg.uuid)",
)
</template>
@@ -47,6 +49,10 @@
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.avatar {
margin-left: -1.75rem;
}
.hr {
width: 100%;
height: 20px;
@@ -70,7 +76,9 @@
}
.card {
border: 0px;
margin-bottom: .5em;
padding: 0rem;
}
</style>

View File

@@ -5,7 +5,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
h3(v-once) {{$t('welcomeTo')}}
.svg-icon.logo(v-html='icons.logoPurple')
.avatar-section.row(:class='{"page-2": modalPage === 2}')
.avatar-section.row(v-if='modalPage > 1', :class='{"page-2": modalPage === 2}')
.col-6.offset-3
.user-creation-bg(v-if='!editing')
avatar(:member='user', :avatarOnly='!editing', :class='{"edit-avatar": editing}')
@@ -187,18 +187,18 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
#extra.section.container.customize-section(v-if='activeTopPage === "extra"')
.row.sub-menu
.col-3.offset-1.text-center.sub-menu-item(@click='changeSubPage("glasses")', :class='{active: activeSubPage === "glasses"}')
strong(v-once) {{$t('glasses')}}
strong(v-once) {{ $t('glasses') }}
.col-4.text-center.sub-menu-item(@click='changeSubPage("wheelchair")', :class='{active: activeSubPage === "wheelchair"}')
strong(v-once) {{$t('wheelchair')}}
strong(v-once) {{ $t('wheelchair') }}
.col-3.text-center.sub-menu-item(@click='changeSubPage("flower")', :class='{active: activeSubPage === "flower"}')
strong(v-once) {{$t('accent')}}
strong(v-once) {{ $t('accent') }}
.row.sub-menu(v-if='editing')
.col-4.text-center.sub-menu-item(@click='changeSubPage("ears")' :class='{active: activeSubPage === "ears"}')
strong(v-once) {{$t('animalEars')}}
strong(v-once) {{ $t('animalEars') }}
.col-4.text-center.sub-menu-item(@click='changeSubPage("tails")' :class='{active: activeSubPage === "tails"}')
strong(v-once) {{$t('animalTails')}}
strong(v-once) {{ $t('animalTails') }}
.col-4.text-center.sub-menu-item(@click='changeSubPage("headband")' :class='{active: activeSubPage === "headband"}')
strong(v-once) {{$t('headband')}}
strong(v-once) {{ $t('headband') }}
#glasses.row(v-if='activeSubPage === "glasses"')
.col-12.customize-options
.option(v-for='option in eyewear', :class='{active: option.active}')
@@ -305,7 +305,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
)
span.svg-icon.inline.icon-12.color(v-html="icons.pin")
.purchase-background.set(v-if='!ownsSet("background", set.items) && set.identifier !== "incentiveBackgrounds"' @click='unlock(setKeys("background", set.items))')
span.label Purchase Set
span.label {{ $t('purchaseAll') }}
.svg-icon.gem(v-html='icons.gem')
span.price 15
.row.customize-menu(v-if='filterBackgrounds')
@@ -320,7 +320,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.container.interests-section(v-if='modalPage === 3 && !editing')
.section.row
.col-12.text-center
h2 I want to work on:
h2 {{ $t('wantToWorkOn') }}
.section.row
.col-6
.task-option
@@ -353,28 +353,35 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
input.custom-control-input#self_care(type="checkbox", value='self_care', v-model='taskCategories')
label.custom-control-label(v-once, for="self_care") {{ $t('self_care') }}
.section.row.justin-message-section(:class='{top: modalPage > 1}', v-if='!editing')
.col-12
.justin-message.d-flex.flex-column.justify-content-center
.featured-label
span.rectangle
span.text Justin
span.rectangle
.npc_justin_textbox
.section.d-flex.justify-content-center(:class='{top: modalPage > 1}', v-if='!editing')
.justin-section.d-flex.align-items-center
.featured-label
span.rectangle
span.text Justin
span.rectangle
.justin-message
.corner-decoration(:style="{top: '-2px', right: '-2px'}")
.corner-decoration(:style="{top: '-2px', left: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', right: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', left: '-2px'}")
div(v-if='modalPage === 1')
p(v-once) {{$t('justinIntroMessage1')}}
p(v-once) {{$t('justinIntroMessage2')}}
p(v-once, v-html='$t("justinIntroMessage1")')
p(v-once) {{ $t('justinIntroMessageUsername') }}
div(v-if='modalPage === 2')
p So how would you like to look? Dont worry, you can change this later.
p {{ $t('justinIntroMessageAppearance') }}
div(v-if='modalPage === 3')
p(v-once) {{$t('justinIntroMessage3')}}
p(v-once) {{ $t('justinIntroMessage3') }}
.npc-justin-textbox
.section.mr-5.ml-5(v-if='modalPage === 1')
username-form(@usernameConfirmed='modalPage += 1', :avatarIntro='true')
.small.text-center(v-html="$t('usernameTOSRequirements')")
.section.container.footer(v-if='!editing')
.row
.section.container.footer
.row(v-if='!editing && !(modalPage === 1)')
.col-3.offset-1.text-center
div(v-if='modalPage > 1', @click='prev()')
.prev-arrow
.prev(v-once) {{$t('prev')}}
.prev(v-once) {{ $t('prev') }}
.col-4.text-center.circles
.circle(:class="{active: modalPage === 1}")
.circle(:class="{active: modalPage === 2}")
@@ -390,12 +397,9 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
<style>
/* @TODO do not rely on avatar-modal___BV_modal_body_,
it already changed once when bootstrap-vue reached version 1 */
.page-2 #avatar-modal___BV_modal_body_ {
margin-top: 9em;
}
.page-2 .modal-content {
margin-top: 7em;
.page-2 #avatar-modal___BV_modal_body_ {
margin-top: 2rem;
}
#avatar-modal___BV_modal_body_, #avatar-modal___BV_modal_body_ {
@@ -421,6 +425,25 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
position: relative;
}
.corner-decoration {
position: absolute;
width: 6px;
height: 6px;
background-color: #ffbe5d;
border: inherit;
outline: inherit;
}
.small {
color: $gray-200;
}
h3 {
font-size: 20px;
font-weight: normal;
color: $gray-200;
}
.purchase-all {
margin-bottom: 1em;
}
@@ -444,7 +467,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.logo {
width: 190px;
margin: 0 auto;
margin: 0 auto 1.25em;
}
.user-creation-bg {
@@ -464,32 +487,22 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
left: 9.2em;
}
.justin-message {
background-image: url('~client/assets/svg/for-css/tutorial-border.svg');
height: 144px;
width: 400px;
padding: 2em;
margin: 0 auto;
.justin-section {
position: relative;
}
.featured-label {
position: absolute;
top: -1em;
.text {
min-height: auto;
color: $white;
}
}
.npc_justin_textbox {
position: absolute;
right: 1em;
top: -3.6em;
width: 48px;
height: 52px;
background-image: url('~client/assets/images/justin_textbox.png');
}
.justin-message {
border-color: #ffa623;
border-style: solid;
border-width: 2px;
outline-color: #b36213;
outline-style: solid;
outline-width: 2px;
position: relative;
padding: 2em;
margin: 2px;
height: 100%;
width: 400px;
p {
margin: auto;
@@ -500,15 +513,27 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
}
}
.justin-message-section {
margin-top: 4em;
margin-bottom: 2em;
.npc-justin-textbox {
position: absolute;
right: 1rem;
top: -3.1rem;
width: 48px;
height: 48px;
background-image: url('~client/assets/images/justin_textbox.png');
}
.justin-message-section.top {
.featured-label {
position: absolute;
top: -16em;
left: 3.5em;
top: -1rem;
left: 1.5rem;
border-radius: 2px;
margin: auto;
.text {
font-size: 12px;
min-height: auto;
color: $white;
}
}
.circles {
@@ -865,6 +890,7 @@ import get from 'lodash/get';
import groupBy from 'lodash/groupBy';
import { mapState } from 'client/libs/store';
import avatar from './avatar';
import usernameForm from './settings/usernameForm';
import { getBackgroundShopSets } from '../../common/script/libs/shops';
import unlock from '../../common/script/ops/unlock';
import buy from '../../common/script/ops/buy/buy';
@@ -1022,6 +1048,7 @@ export default {
components: {
avatar,
toggleSwitch,
usernameForm,
},
mounted () {
if (this.editing) this.modalPage = 2;

View File

@@ -19,6 +19,7 @@
v-on:select="selectedAutocomplete",
:textbox='textbox',
:coords='coords',
:caretPosition = 'caretPosition',
:chat='group.chat')
.row.chat-actions
@@ -56,6 +57,7 @@
data () {
return {
newMessage: '',
caretPosition: 0,
chat: {
submitDisable: false,
submitTimeout: null,
@@ -75,7 +77,7 @@
methods: {
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
getCoord (e, text) {
let carPos = text.selectionEnd;
this.caretPosition = text.selectionEnd;
let div = document.createElement('div');
let span = document.createElement('span');
let copyStyle = getComputedStyle(text);
@@ -86,8 +88,8 @@
div.style.position = 'absolute';
document.body.appendChild(div);
div.textContent = text.value.substr(0, carPos);
span.textContent = text.value.substr(carPos) || '.';
div.textContent = text.value.substr(0, this.caretPosition);
span.textContent = text.value.substr(this.caretPosition) || '.';
div.appendChild(span);
this.coords = {
TOP: span.offsetTop,
@@ -194,7 +196,6 @@
width: 100%;
background-color: $white;
border: solid 1px $gray-400;
font-size: 16px;
font-style: italic;
line-height: 1.43;
color: $gray-300;

View File

@@ -1,152 +1,199 @@
<template lang="pug">
b-modal#invite-modal(:title="$t('inviteFriends')", size='lg')
.modal-body
p.alert.alert-info(v-html="$t('inviteAlertInfo')")
.form-horizontal
table.table.table-striped
thead
tr
th {{ $t('userId') }}
tbody
tr(v-for='user in invitees')
td
input.form-control(type='text', v-model='user.uuid')
tr
td
button.btn.btn-primary.pull-right(@click='addUuid()')
i.glyphicon.glyphicon-plus
| +
tr
td
.col-6.col-offset-6
button.btn.btn-primary.btn-block(@click='inviteNewUsers("uuid")') {{sendInviteText}}
hr
p.alert.alert-info {{ $t('inviteByEmail') }}
.form-horizontal
table.table.table-striped
thead
tr
th {{ $t('name') }}
th {{ $t('email') }}
tbody
tr(v-for='email in emails')
td
input.form-control(type='text', v-model='email.name')
td
input.form-control(type='email', v-model='email.email')
tr
td(colspan=2)
button.btn.btn-primary.pull-right(@click='addEmail()')
i.glyphicon.glyphicon-plus
| +
tr
td.form-group(colspan=2)
label.col-sm-1.control-label {{ $t('byColon') }}
.col-sm-5
input.form-control(type='text', v-model='inviter')
.col-sm-6
button.btn.btn-primary.btn-block(@click='inviteNewUsers("email")') {{sendInviteText}}
b-modal#invite-modal(:title='$t(`inviteTo${groupType}`)', :hide-footer='true')
div
strong {{ $t('inviteEmailUsername') }}
.small {{ $t('inviteEmailUsernameInfo') }}
div(v-for='(invite, index) in invites')
.input-group
.d-flex.align-items-center.justify-content-center(v-if='index === invites.length - 1 && invite.text.length === 0')
.svg-icon.positive-icon(v-html='icons.positiveIcon')
input.form-control(
type='text',
:placeholder='$t("emailOrUsernameInvite")',
v-model='invite.text',
v-on:keyup='expandInviteList',
v-on:change='checkInviteList',
:class='{"input-valid": invite.valid, "is-invalid input-invalid": invite.valid === false}',
)
.input-error.text-center.mt-2(v-if="invite.error") {{ invite.error }}
.modal-footer.d-flex.justify-content-center
a.mr-3(@click='close()') {{ $t('cancel') }}
button.btn.btn-primary(@click='sendInvites()', :class='{disabled: cannotSubmit}', :disabled='cannotSubmit') {{ $t('sendInvitations') }}
</template>
<style lang="scss">
#invite-modal___BV_modal_outer_ {
.modal-content {
padding: 0rem 0.25rem;
}
}
#invite-modal___BV_modal_header_.modal-header {
border-bottom: 0px;
}
#invite-modal___BV_modal_header_ {
.modal-title {
color: #4F2A93;
font-size: 24px;
}
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
a:not([href]) {
color: $blue-10;
font-size: 16px;
}
.form-control {
border: 0px;
color: $gray-50;
}
.input-error {
color: $red-50;
font-size: 90%;
}
.input-group {
border-radius: 2px;
border: solid 1px $gray-400;
margin-top: 0.5rem;
}
::placeholder {
color: $gray-200;
opacity: 1;
}
.input-group:focus-within {
border-color: $purple-500;
}
.modal-footer {
border: 0px;
}
.positive-icon {
color: $green-10;
width: 10px;
margin: auto 0rem auto 1rem;
}
.small {
color: $gray-200;
font-size: 12px;
margin: 0.5rem 0rem 1rem;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import { mapState } from 'client/libs/store';
import clone from 'lodash/clone';
import debounce from 'lodash/debounce';
import filter from 'lodash/filter';
import forEach from 'lodash/forEach';
import isEmail from 'validator/lib/isEmail';
import isUUID from 'validator/lib/isUUID';
import notifications from 'client/mixins/notifications';
import positiveIcon from 'assets/svg/positive.svg';
import filter from 'lodash/filter';
import map from 'lodash/map';
import notifications from 'client/mixins/notifications';
const INVITE_DEFAULTS = {text: '', error: null, valid: null};
export default {
mixins: [notifications],
props: ['group'],
data () {
return {
invitees: [],
emails: [],
};
},
computed: {
...mapState({user: 'user.data'}),
inviter () {
return this.user.profile.name;
export default {
computed: {
...mapState({user: 'user.data'}),
cannotSubmit () {
const filteredInvites = filter(this.invites, (invite) => {
return invite.text.length > 0 && !invite.valid;
});
if (filteredInvites.length > 0) return true;
},
inviter () {
return this.user.profile.name;
},
},
sendInviteText () {
return 'Send Invites';
// if (!this.group) return 'Send Invites';
// return this.group.sendInviteText;
data () {
return {
invites: [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)],
icons: Object.freeze({
positiveIcon,
}),
};
},
},
methods: {
addUuid () {
this.invitees.push({uuid: ''});
methods: {
checkInviteList: debounce(function checkList () {
this.invites = filter(this.invites, (invite, index) => {
return invite.text.length > 0 || index === this.invites.length - 1;
});
while (this.invites.length < 2) this.invites.push(clone(INVITE_DEFAULTS));
forEach(this.invites, (value, index) => {
if (value.text.length < 1 || isEmail(value.text)) {
return this.fillErrors(index);
}
if (isUUID(value.text)) {
this.$store.dispatch('user:userLookup', {uuid: value.text})
.then(res => {
return this.fillErrors(index, res);
});
} else {
let searchUsername = value.text;
if (searchUsername[0] === '@') searchUsername = searchUsername.slice(1, searchUsername.length);
this.$store.dispatch('user:userLookup', {username: searchUsername})
.then(res => {
return this.fillErrors(index, res);
});
}
});
}, 250),
expandInviteList () {
if (this.invites[this.invites.length - 1].text.length > 0) this.invites.push(clone(INVITE_DEFAULTS));
},
fillErrors (index, res) {
if (!res || res.status === 200) {
this.invites[index].error = null;
if (this.invites[index].text.length < 1) return this.invites[index].valid = null;
return this.invites[index].valid = true;
}
this.invites[index].error = res.response.data.message;
return this.invites[index].valid = false;
},
close () {
this.invites = [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)];
this.$root.$emit('bv::hide::modal', 'invite-modal');
},
async sendInvites () {
let invitationDetails = {
inviter: this.inviter,
emails: [],
uuids: [],
usernames: [],
};
forEach(this.invites, (invite) => {
if (invite.text.length < 1) return;
if (isEmail(invite.text)) {
invitationDetails.emails.push({email: invite.text});
} else if (isUUID(invite.text)) {
invitationDetails.uuids.push(invite.text);
} else {
invitationDetails.usernames.push(invite.text);
}
});
await this.$store.dispatch('guilds:invite', {
invitationDetails,
groupId: this.group._id,
});
const invitesSent = invitationDetails.emails.length + invitationDetails.uuids.length + invitationDetails.usernames.length;
let invitationString = invitesSent > 1 ? 'invitationsSent' : 'invitationSent';
this.text(this.$t(invitationString));
this.close();
},
},
addEmail () {
this.emails.push({name: '', email: ''});
},
inviteNewUsers (inviteMethod) {
if (!this.group._id) {
if (!this.group.name) this.group.name = this.$t('possessiveParty', {name: this.user.profile.name});
// @TODO: Add dispatch
// return Groups.Group.create(this.group)
// .then(function(response) {
// this.group = response.data.data;
// _inviteByMethod(inviteMethod);
// });
}
this.inviteByMethod(inviteMethod);
},
async inviteByMethod (inviteMethod) {
let invitationDetails;
if (inviteMethod === 'email') {
let emails = this.getEmails();
invitationDetails = { inviter: this.inviter, emails };
} else if (inviteMethod === 'uuid') {
let uuids = this.getOnlyUuids();
invitationDetails = { uuids };
} else {
return alert('Invalid invite method.');
}
await this.$store.dispatch('guilds:invite', {
invitationDetails,
groupId: this.group._id,
});
let invitesSent = invitationDetails.emails || invitationDetails.uuids;
let invitationString = invitesSent.length > 1 ? 'invitationsSent' : 'invitationSent';
this.text(this.$t(invitationString));
this.invitees = [];
this.emails = [];
// @TODO: This function didn't make it over this.resetInvitees();
// @TODO: Sync group invites?
// if (this.group.type === 'party') {
// this.$router.push('//party');
// } else {
// this.$router.push(`/groups/guilds/${this.group._id}`);
// }
this.$root.$emit('bv::hide::modal', 'invite-modal');
// @TODO: error?
// _resetInvitees();
},
getOnlyUuids () {
let uuids = map(this.invitees, 'uuid');
let filteredUuids = filter(uuids, (id) => {
return id !== '';
});
return filteredUuids;
},
getEmails () {
let emails = filter(this.emails, (obj) => {
return obj.email !== '';
});
return emails;
},
},
};
mixins: [notifications],
props: ['group', 'groupType'],
};
</script>

View File

@@ -355,7 +355,10 @@ export default {
sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: member._id,
userName: member.profile.name,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
});
},
async searchMembers (searchTerm = '') {

View File

@@ -1,6 +1,6 @@
<template lang="pug">
div
invite-modal(:group='inviteModalGroup')
invite-modal(:group='inviteModalGroup', :groupType='inviteModalGroupType')
create-party-modal
#app-header.row(:class="{'hide-header': $route.name === 'groupPlan'}")
members-modal(:hide-badge="true")
@@ -115,6 +115,7 @@ export default {
expandedMember: null,
currentWidth: 0,
inviteModalGroup: undefined,
inviteModalGroupType: undefined,
};
},
computed: {
@@ -178,6 +179,7 @@ export default {
mounted () {
this.$root.$on('inviteModal::inviteToGroup', (group) => {
this.inviteModalGroup = group;
this.inviteModalGroupType = group.type === 'guild' ? 'Guild' : 'Party';
this.$root.$emit('bv::show::modal', 'invite-modal');
});
},

View File

@@ -19,7 +19,10 @@
| {{member.profile.name}}
.is-buffed(v-if="isBuffed", v-b-tooltip.hover.bottom="$t('buffed')")
.svg-icon(v-html="icons.buff")
span.small-text.character-level {{ characterLevel }}
.small-text.character-level
span.mr-1(v-if="member.auth && member.auth.local && member.auth.local.username") @{{ member.auth.local.username }}
span.mr-1(v-if="member.auth && member.auth.local && member.auth.local.username")
span {{ characterLevel }}
.progress-container(v-b-tooltip.hover.bottom="$t('health')")
.svg-icon(v-html="icons.health")
.progress

View File

@@ -25,6 +25,7 @@ div
login-incentives(:data='notificationData')
quest-completed
quest-invitation
verify-username
</template>
<style lang='scss'>
@@ -118,6 +119,7 @@ import streak from './achievements/streak';
import ultimateGear from './achievements/ultimateGear';
import wonChallenge from './achievements/wonChallenge';
import loginIncentives from './achievements/login-incentives';
import verifyUsername from './settings/verifyUsername';
const NOTIFICATIONS = {
CHALLENGE_JOINED_ACHIEVEMENT: {
@@ -178,6 +180,7 @@ export default {
dropsEnabled,
contributor,
loginIncentives,
verifyUsername,
},
data () {
// Levels that already display modals and should not trigger generic Level Up
@@ -314,16 +317,12 @@ export default {
this.$store.dispatch('user:fetch'),
this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => {
this.debounceCheckUserAchievements();
// @TODO: This is a timeout to ensure dom is loaded
window.setTimeout(() => {
this.initTour();
if (this.user.flags.tour.intro === this.TOUR_END || !this.user.flags.welcomed) return;
this.goto('intro', 0);
this.runForcedModals();
}, 2000);
this.runYesterDailies();
this.debounceCheckUserAchievements();
// Do not remove the event listener as it's live for the entire app lifetime
document.addEventListener('mousemove', this.checkNextCron);
@@ -339,6 +338,11 @@ export default {
document.removeEventListener('keydown', this.checkNextCron);
},
methods: {
runForcedModals () {
if (!this.user.flags.verifiedUsername) return this.$root.$emit('bv::show::modal', 'verify-username');
return this.runYesterDailies();
},
showDeathModal () {
this.playSound('Death');
this.$root.$emit('bv::show::modal', 'death');
@@ -413,21 +417,25 @@ export default {
// List of prompts for user on changes. Sounds like we may need a refactor here, but it is clean for now
if (!this.user.flags.welcomed) {
this.$store.state.avatarEditorOptions.editingUser = false;
this.$root.$emit('bv::show::modal', 'avatar-modal');
return this.$root.$emit('bv::show::modal', 'avatar-modal');
}
if (this.user.flags.newStuff) {
return this.$root.$emit('bv::show::modal', 'new-stuff');
}
if (this.user.stats.hp <= 0) {
this.showDeathModal();
return this.showDeathModal();
}
if (this.questCompleted) {
this.$root.$emit('bv::show::modal', 'quest-completed');
this.playSound('Achievement_Unlocked');
return this.$root.$emit('bv::show::modal', 'quest-completed');
}
if (this.userClassSelect) {
this.$root.$emit('bv::show::modal', 'choose-class');
this.playSound('Achievement_Unlocked');
return this.$root.$emit('bv::show::modal', 'choose-class');
}
},
showLevelUpNotifications (newlevel) {
@@ -520,10 +528,6 @@ export default {
async handleUserNotifications (after) {
if (this.$store.state.isRunningYesterdailies) return;
if (this.user.flags.newStuff) {
this.$root.$emit('bv::show::modal', 'new-stuff');
}
if (!after || after.length === 0 || !Array.isArray(after)) return;
let notificationsToRead = [];

View File

@@ -130,8 +130,10 @@
h5 {{ $t('changeDisplayName') }}
.form(name='changeDisplayName', novalidate)
.form-group
input#changeDisplayname.form-control(type='text', :placeholder="$t('newDisplayName')", v-model='temporaryDisplayName')
button.btn.btn-primary(type='submit', @click='changeDisplayName(temporaryDisplayName)') {{ $t('submit') }}
input#changeDisplayname.form-control(type='text', :placeholder="$t('newDisplayName')", v-model='temporaryDisplayName', :class='{"is-invalid input-invalid": displayNameInvalid}')
.mb-3(v-if="displayNameIssues.length > 0")
.input-error(v-for="issue in displayNameIssues") {{ issue }}
button.btn.btn-primary(type='submit', @click='changeDisplayName(temporaryDisplayName)', :disabled='displayNameCannotSubmit') {{ $t('submit') }}
h5 {{ $t('changeUsername') }}
.form(name='changeUsername', novalidate)
@@ -252,6 +254,7 @@ export default {
password: '',
confirmPassword: '',
},
displayNameIssues: [],
usernameIssues: [],
};
},
@@ -312,6 +315,18 @@ export default {
verifiedUsername () {
return this.user.flags.verifiedUsername;
},
displayNameInvalid () {
if (this.temporaryDisplayName.length <= 1) return false;
return !this.displayNameValid;
},
displayNameValid () {
if (this.temporaryDisplayName.length <= 1) return false;
return this.displayNameIssues.length === 0;
},
displayNameCannotSubmit () {
if (this.temporaryDisplayName.length <= 1) return true;
return !this.displayNameValid;
},
usernameValid () {
if (this.usernameUpdates.username.length <= 1) return false;
return this.usernameIssues.length === 0;
@@ -332,10 +347,30 @@ export default {
},
deep: true,
},
temporaryDisplayName: {
handler () {
this.validateDisplayName(this.temporaryDisplayName);
},
deep: true,
},
},
methods: {
// eslint-disable-next-line func-names
validateUsername: debounce(function (username) {
validateDisplayName: debounce(function checkName (displayName) {
if (displayName.length <= 1 || displayName === this.user.profile.name) {
this.displayNameIssues = [];
return;
}
this.$store.dispatch('auth:verifyDisplayName', {
displayName,
}).then(res => {
if (res.issues !== undefined) {
this.displayNameIssues = res.issues;
} else {
this.displayNameIssues = [];
}
});
}, 500),
validateUsername: debounce(function checkName (username) {
if (username.length <= 1 || username === this.user.auth.local.username) {
this.usernameIssues = [];
return;

View File

@@ -0,0 +1,221 @@
<template lang="pug">
div
.form-group
.d-flex.align-items-center
label.mr-3(for='displayName') {{ $t('displayName') }}
.flex-grow-1
input#displayName.form-control(
type='text',
:placeholder="$t('newDisplayName')",
v-model='temporaryDisplayName',
@blur='restoreEmptyDisplayName()',
:class='{"is-invalid input-invalid": displayNameInvalid, "input-valid": displayNameValid, "text-darker": temporaryDisplayName.length > 0}')
.mb-3(v-if="displayNameIssues.length > 0")
.input-error.text-center(v-for="issue in displayNameIssues") {{ issue }}
.form-group
.d-flex.align-items-center
label.mr-3(for='username') {{ $t('username') }}
.flex-grow-1
.input-group-prepend.input-group-text @
input#username.form-control(
type='text',
:placeholder="$t('newUsername')",
v-model='temporaryUsername',
@blur='restoreEmptyUsername()',
:class='{"is-invalid input-invalid": usernameInvalid, "input-valid": usernameValid, "text-darker": temporaryUsername.length > 0}')
.mb-3(v-if="usernameIssues.length > 0")
.input-error.text-center(v-for="issue in usernameIssues") {{ issue }}
.small.text-center.mb-3(v-if='!avatarIntro') {{ $t('usernameLimitations') }}
.row.justify-content-center
button.btn.btn-primary(type='submit', @click='submitNames()', :class='{disabled: usernameCannotSubmit}', :disabled='usernameCannotSubmit') {{ $t(avatarIntro ? 'getStarted' : 'saveAndConfirm') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
button {
margin: 0.25rem auto 1rem;
}
.col-3 {
padding-right: 0rem;
}
.form-control {
height: 2.25rem;
}
.form-group {
background-color: $gray-700;
border-radius: 2px;
border: solid 1px $gray-500;
}
input {
border: 0px;
}
.input-error {
color: $red-50;
font-size: 90%;
width: 100%;
}
.input-group-prepend {
margin-right: 0px;
}
.input-group-text {
background-color: $white;
border: 0px;
border-radius: 0px;
color: $gray-300;
padding: 0rem 0.1rem 0rem 0.75rem;
}
label {
color: $gray-100;
font-weight: bold;
margin-bottom: 0rem;
margin-left: 1rem;
min-width: 90px;
}
.small {
color: $gray-200;
}
.text-darker {
color: $gray-50;
}
#username {
padding-left: 0.25rem;
}
</style>
<script>
import axios from 'axios';
import debounce from 'lodash/debounce';
import { mapState } from 'client/libs/store';
export default {
computed: {
...mapState({
user: 'user.data',
}),
displayNameInvalid () {
if (this.temporaryDisplayName.length < 1) return false;
return !this.displayNameValid;
},
displayNameValid () {
if (this.temporaryDisplayName.length < 1) return false;
return this.displayNameIssues.length === 0;
},
usernameCannotSubmit () {
if (this.temporaryUsername.length < 1) return true;
return !this.usernameValid || !this.displayNameValid;
},
usernameInvalid () {
if (this.temporaryUsername.length < 1) return false;
return !this.usernameValid;
},
usernameValid () {
if (this.temporaryUsername.length < 1) return false;
return this.usernameIssues.length === 0;
},
},
data () {
return {
displayNameIssues: [],
temporaryDisplayName: '',
temporaryUsername: '',
usernameIssues: [],
};
},
methods: {
async close () {
this.$root.$emit('habitica::resync-requested');
await this.$store.dispatch('user:fetch', {forceLoad: true});
this.$root.$emit('habitica::resync-completed');
if (this.avatarIntro) {
this.$emit('usernameConfirmed');
} else {
this.$root.$emit('bv::hide::modal', 'verify-username');
this.$router.go(0);
}
},
restoreEmptyDisplayName () {
if (this.temporaryDisplayName.length < 1) {
this.temporaryDisplayName = this.user.profile.name;
}
},
restoreEmptyUsername () {
if (this.temporaryUsername.length < 1) {
this.temporaryUsername = this.user.auth.local.username;
}
},
async submitNames () {
if (this.temporaryDisplayName !== this.user.profile.name) {
await axios.put('/api/v4/user/', {'profile.name': this.temporaryDisplayName});
}
await axios.put('/api/v4/user/auth/update-username', {username: this.temporaryUsername});
this.close();
},
validateDisplayName: debounce(function checkName (displayName) {
if (displayName.length <= 1 || displayName === this.user.profile.name) {
this.displayNameIssues = [];
return;
}
this.$store.dispatch('auth:verifyDisplayName', {
displayName,
}).then(res => {
if (res.issues !== undefined) {
this.displayNameIssues = res.issues;
} else {
this.displayNameIssues = [];
}
});
}, 500),
validateUsername: debounce(function checkName (username) {
if (username.length <= 1 || username === this.user.auth.local.username) {
this.usernameIssues = [];
return;
}
this.$store.dispatch('auth:verifyUsername', {
username,
}).then(res => {
if (res.issues !== undefined) {
this.usernameIssues = res.issues;
} else {
this.usernameIssues = [];
}
});
}, 500),
},
mounted () {
this.temporaryDisplayName = this.user.profile.name;
this.temporaryUsername = this.user.auth.local.username;
},
props: {
avatarIntro: {
type: Boolean,
default: false,
},
},
watch: {
temporaryDisplayName: {
handler () {
this.validateDisplayName(this.temporaryDisplayName);
},
deep: true,
},
temporaryUsername: {
handler () {
this.validateUsername(this.temporaryUsername);
},
deep: true,
},
},
};
</script>

View File

@@ -0,0 +1,84 @@
<template lang="pug">
b-modal#verify-username(
size="m",
:no-close-on-backdrop="true",
:no-close-on-esc="true",
:hide-header="true",
:hide-footer="true",
@hide="$emit('hide')",
).d-flex
div.nametag-header(v-html='icons.helloNametag')
h2.text-center {{ $t('usernameTime') }}
p.text-center(v-html="$t('usernameInfo')")
username-form
.scene_veteran_pets.center-block
.small.text-center.mb-3 {{ $t('verifyUsernameVeteranPet') }}
.small.text-center.tos-footer(v-html="$t('usernameTOSRequirements')")
</template>
<style lang="scss">
#verify-username___BV_modal_outer_ {
.modal-content {
height: 100%;
width: 566px;
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.center-block {
margin: 0 auto 1em auto;
}
h2 {
color: $purple-200;
margin-top: 1.5rem;
}
.nametag-header {
background-color: $gray-700;
border-radius: 0.3rem 0.3rem 0rem 0rem;
margin-left: -3rem;
margin-right: -3rem;
padding: 1rem 9rem 1rem 9rem;
}
p {
color: $gray-100;
}
.small {
color: $gray-200;
}
.tos-footer {
background-color: $gray-700;
border-radius: 0rem 0rem 0.3rem 0.3rem;
margin-left: -3rem;
margin-right: -3rem;
margin-top: -0.1rem;
padding: 1rem 4rem 1rem 4rem;
}
</style>
<script>
import helloNametag from 'assets/svg/hello-habitican.svg';
import usernameForm from './usernameForm';
export default {
components: {
usernameForm,
},
data () {
return {
icons: Object.freeze({
helloNametag,
}),
};
},
};
</script>

View File

@@ -9,7 +9,7 @@
position: fixed;
right: 10px;
width: 350px;
z-index: 1070; // 1070 is above modal backgrounds
z-index: 1400; // 1400 is above modal backgrounds
&-top-pos {
&-normal {

View File

@@ -560,6 +560,7 @@
<script>
import hello from 'hellojs';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import googlePlay from 'assets/images/home/google-play-badge.svg';
import iosAppStore from 'assets/images/home/ios-app-store.svg';
import iphones from 'assets/images/home/iphones.svg';
@@ -626,18 +627,18 @@
computed: {
emailValid () {
if (this.email.length <= 3) return false;
return this.validateEmail(this.email);
return isEmail(this.email);
},
emailInvalid () {
if (this.email.length <= 3) return false;
return !this.validateEmail(this.email);
return !isEmail(this.email);
},
usernameValid () {
if (this.username.length <= 3) return false;
if (this.username.length < 1) return false;
return this.usernameIssues.length === 0;
},
usernameInvalid () {
if (this.username.length <= 3) return false;
if (this.username.length < 1) return false;
return !this.usernameValid;
},
passwordConfirmValid () {
@@ -655,13 +656,9 @@
},
},
methods: {
validateEmail (email) {
let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
},
// eslint-disable-next-line func-names
validateUsername: debounce(function (username) {
if (username.length <= 3) {
if (username.length < 1) {
return;
}
this.$store.dispatch('auth:verifyUsername', {

View File

@@ -30,8 +30,11 @@
.conversation(v-for='conversation in filtersConversations', @click='selectConversation(conversation.key)',
:class="{active: selectedConversation.key === conversation.key}")
div
span(:class="userLevelStyle(conversation)") {{conversation.name}}
span.timeago {{conversation.date | timeAgo}}
h3(:class="userLevelStyle(conversation)") {{ conversation.name }}
.svg-icon(v-html="tierIcon(conversation)")
.time
span.mr-1(v-if='conversation.username') @{{ conversation.username }}
span {{ conversation.date | timeAgo }}
div {{conversation.lastMessageText ? conversation.lastMessageText.substring(0, 30) : ''}}
.col-8.messages.d-flex.flex-column.justify-content-between
.empty-messages.text-center(v-if='!selectedConversation.key')
@@ -63,6 +66,7 @@
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss';
.header-wrap {
padding: 0.5em;
@@ -73,6 +77,16 @@
}
}
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.envelope {
color: $gray-400 !important;
margin: 0;
@@ -180,11 +194,6 @@
.conversation {
padding: 1.5em;
background: $white;
height: 80px;
.timeago {
margin-left: 1em;
}
}
.conversation.active {
@@ -194,6 +203,12 @@
.conversation:hover {
cursor: pointer;
}
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
</style>
<script>
@@ -207,9 +222,19 @@ import styleHelper from 'client/mixins/styleHelper';
import toggleSwitch from 'client/components/ui/toggleSwitch';
import axios from 'axios';
import messageIcon from 'assets/svg/message.svg';
import chatMessages from '../chat/chatMessages';
import messageIcon from 'assets/svg/message.svg';
import svgClose from 'assets/svg/close.svg';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tier8 from 'assets/svg/tier-mod.svg';
import tier9 from 'assets/svg/tier-staff.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
export default {
mixins: [styleHelper],
@@ -236,8 +261,11 @@ export default {
}
this.initiatedConversation = {
user: data.userName,
uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
};
this.selectConversation(data.userIdToMessage);
@@ -252,6 +280,16 @@ export default {
icons: Object.freeze({
messageIcon,
svgClose,
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
displayCreate: true,
selectedConversation: {},
@@ -276,10 +314,11 @@ export default {
// Add placeholder for new conversations
if (this.initiatedConversation && this.initiatedConversation.uuid) {
inboxGroup[this.initiatedConversation.uuid] = [{
uuid: this.initiatedConversation.uuid,
user: this.initiatedConversation.user,
username: this.initiatedConversation.username,
id: '',
text: '',
user: this.initiatedConversation.user,
uuid: this.initiatedConversation.uuid,
timestamp: new Date(),
}];
}
@@ -296,8 +335,12 @@ export default {
if (newChat.sent) {
newChat.toUUID = newChat.uuid;
newChat.toUser = newChat.user;
newChat.toUserName = newChat.username;
newChat.toUserContributor = newChat.contributor;
newChat.toUserBacker = newChat.backer;
newChat.uuid = this.user._id;
newChat.user = this.user.profile.name;
newChat.username = this.user.auth.local.username;
newChat.contributor = this.user.contributor;
newChat.backer = this.user.backer;
}
@@ -309,11 +352,12 @@ export default {
if (!recentMessage.text) newChatModels.splice(newChatModels.length - 1, 1);
const convoModel = {
name: recentMessage.toUser ? recentMessage.toUser : recentMessage.user, // Handles case where from user sent the only message or the to user sent the only message
key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid,
messages: newChatModels,
lastMessageText: recentMessage.text,
name: recentMessage.toUser ? recentMessage.toUser : recentMessage.user, // Handles case where from user sent the only message or the to user sent the only message
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
messages: newChatModels,
};
convos.push(convoModel);
@@ -382,8 +426,11 @@ export default {
const messageIndex = this.messages.findIndex(msg => msg.id === message.id);
if (messageIndex !== -1) this.messages.splice(messageIndex, 1);
if (this.selectedConversationMessages.length === 0) this.initiatedConversation = {
user: this.selectedConversation.name,
uuid: this.selectedConversation.key,
user: this.selectedConversation.name,
username: this.selectedConversation.username,
backer: this.selectedConversation.backer,
contributor: this.selectedConversation.contributor,
};
},
toggleClick () {
@@ -413,6 +460,7 @@ export default {
text: this.newMessage,
timestamp: new Date(),
user: this.selectedConversation.name,
username: this.selectedConversation.username,
uuid: this.selectedConversation.key,
contributor: this.user.contributor,
});
@@ -444,6 +492,14 @@ export default {
close () {
this.$root.$emit('bv::hide::modal', 'inbox-modal');
},
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
if (!message.contributor) return;
return this.icons[`tier${message.contributor.level}`];
},
},
};
</script>

View File

@@ -40,11 +40,12 @@ div
#userProfile.standard-page(v-show='selectedPage === "profile"', v-if='user.profile')
.row
.col-12.col-md-8
.header
.header.mb-3
h1 {{user.profile.name}}
h4
strong {{ $t('userId') }}:&nbsp;
| {{user._id}}
div
.name(v-if='user.auth && user.auth.local && user.auth.local.username') @{{ user.auth.local.username }}
div
.name {{ user._id }}
.col-12.col-md-4
button.btn.btn-secondary(v-if='user._id === userLoggedIn._id', @click='editing = !editing') {{ $t('edit') }}
.row(v-if='!editing')
@@ -146,7 +147,7 @@ div
#profile {
.member-details {
.character-name, small, .small-text {
color: #878190
color: #878190;
}
}
@@ -193,7 +194,7 @@ div
.gift-icon {
width: 14px;
margin: auto;
color: #686274;
color: $gray-100;
}
.gift-icon {
@@ -201,13 +202,13 @@ div
}
.remove-icon {
width:16px;
color: #686274;
width: 16px;
color: $gray-100;
}
.positive-icon {
width: 14px;
color: #686274;
color: $gray-100;
}
.photo img {
@@ -216,11 +217,12 @@ div
.header {
h1 {
color: #4f2a93;
color: $purple-200;
margin-bottom: 0rem;
}
h4 {
color: #686274;
color: $gray-100;
}
}
@@ -241,6 +243,11 @@ div
cursor: pointer;
}
.name {
color: $gray-200;
font-size: 16px;
}
#achievements {
.box {
margin: 0 auto;
@@ -463,7 +470,10 @@ export default {
sendMessage () {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: this.user._id,
userName: this.user.profile.name,
displayName: this.user.profile.name,
username: this.user.auth.local.username,
backer: this.user.backer,
contributor: this.user.contributor,
});
},
getProgressDisplay () {

View File

@@ -55,6 +55,15 @@ export async function verifyUsername (store, params) {
return result.data.data;
}
export async function verifyDisplayName (store, params) {
let url = '/api/v4/user/auth/verify-display-name';
let result = await axios.post(url, {
displayName: params.displayName,
});
return result.data.data;
}
export async function socialAuth (store, params) {
let url = '/api/v4/user/auth/social';
let result = await axios.post(url, {

View File

@@ -164,6 +164,7 @@ export async function invite (store, payload) {
let response = await axios.post(`/api/v4/groups/${payload.groupId}/invite`, {
uuids: payload.invitationDetails.uuids,
emails: payload.invitationDetails.emails,
usernames: payload.invitationDetails.usernames,
});
// @TODO: find guild and add invites

View File

@@ -151,3 +151,14 @@ export async function togglePrivateMessagesOpt (store) {
store.state.user.data.inbox.optOut = !store.state.user.data.inbox.optOut;
return response;
}
export async function userLookup (store, params) {
let response;
if (params.uuid) {
response = await axios.get(`/api/v4/members/${params.uuid}`);
}
if (params.username) {
response = await axios.get(`/api/v4/members/username/${params.username}`);
}
return response;
}

View File

@@ -7,7 +7,7 @@
"noPhoto": "This Habitican hasn't added a photo.",
"other": "Other",
"fullName": "Full Name",
"displayName": "Display Name",
"displayName": "Display name",
"changeDisplayName": "Change Display Name",
"newDisplayName": "New Display Name",
"displayPhoto": "Photo",

View File

@@ -271,15 +271,9 @@
"emailTaken": "Email address is already used in an account.",
"newEmailRequired": "Missing new email address.",
"usernameTime": "It's time to set your username!",
"usernameInfo": "Your display name hasn't changed, but your old login name will now become your public username. This username will be used for invitations, @mentions in chat, and messaging.<br><br>If you'd like to learn more about this change, visit the wiki's <a href='http://habitica.wikia.com/wiki/Player_Names' target='_blank'>Player Names</a> page.",
"usernameTOSRequirements": "Usernames must conform to our Terms of Service and Community Guidelines. If you didnt previously set a login name, your username was auto-generated.",
"usernameInfo": "Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.<br><br>If you'd like to learn more about this change, <a href='http://habitica.wikia.com/wiki/Player_Names' target='_blank'>visit our wiki</a>.",
"usernameTOSRequirements": "Usernames must conform to our <a href='/static/terms' target='_blank'>Terms of Service</a> and <a href='/static/community-guidelines' target='_blank'>Community Guidelines</a>. If you didnt previously set a login name, your username was auto-generated.",
"usernameTaken": "Username already taken.",
"usernameWrongLength": "Username must be between 1 and 20 characters long.",
"displayNameWrongLength": "Display names must be between 1 and 30 characters long.",
"usernameBadCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.",
"nameBadWords": "Names cannot include any inappropriate words.",
"confirmUsername": "Confirm Username",
"usernameConfirmed": "Username Confirmed",
"passwordConfirmationMatch": "Password confirmation doesn't match password.",
"invalidLoginCredentials": "Incorrect username and/or email and/or password.",
"passwordResetPage": "Reset Password",
@@ -335,7 +329,7 @@
"joinToday": "Join Habitica Today",
"featuredIn": "Featured in",
"signup": "Sign Up",
"getStarted": "Get Started",
"getStarted": "Get Started!",
"mobileApps": "Mobile Apps",
"learnMore": "Learn More"
}

View File

@@ -254,6 +254,7 @@
"resetFilters": "Clear all filters",
"applyFilters": "Apply Filters",
"wantToWorkOn": "I want to work on:",
"categories": "Categories",
"habiticaOfficial": "Habitica Official",
"animals": "Animals",

View File

@@ -185,7 +185,7 @@
"inviteExistUser": "Invite Existing Users",
"byColon": "By:",
"inviteNewUsers": "Invite New Users",
"sendInvitations": "Send Invitations",
"sendInvitations": "Send Invites",
"invitationsSent": "Invitations sent!",
"invitationSent": "Invitation sent!",
"invitedFriend": "Invited a Friend",
@@ -229,7 +229,7 @@
"memberCannotRemoveYourself": "You cannot remove yourself!",
"groupMemberNotFound": "User not found among group's members",
"mustBeGroupMember": "Must be member of the group.",
"canOnlyInviteEmailUuid": "Can only invite using uuids or emails.",
"canOnlyInviteEmailUuid": "Can only invite using user IDs, emails, or usernames.",
"inviteMissingEmail": "Missing email address in invite.",
"inviteMissingUuid": "Missing user id in invite",
"inviteMustNotBeEmpty": "Invite must not be empty.",
@@ -241,9 +241,11 @@
"userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.",
"userAlreadyInAParty": "UserID: <%= userId %>, User \"<%= username %>\" already in a party. ",
"userWithIDNotFound": "User with id \"<%= userId %>\" not found.",
"userWithUsernameNotFound": "User with username \"<%= username %>\" not found.",
"userHasNoLocalRegistration": "User does not have a local registration (username, email, password).",
"uuidsMustBeAnArray": "User ID invites must be an array.",
"emailsMustBeAnArray": "Email address invites must be an array.",
"usernamesMustBeAnArray": "Username invites must be an array.",
"canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
"partyExceedsMembersLimit": "Party size is limited to <%= maxMembersParty %> members",
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
@@ -366,6 +368,10 @@
"liked": "Liked",
"joinGuild": "Join Guild",
"inviteToGuild": "Invite to Guild",
"inviteToParty": "Invite to Party",
"inviteEmailUsername": "Invite via Email or Username",
"inviteEmailUsernameInfo": "Invite users via a valid email or username. If an email isn't registered yet, we'll invite them to join.",
"emailOrUsernameInvite": "Email address or username",
"messageGuildLeader": "Message Guild Leader",
"donateGems": "Donate Gems",
"updateGuild": "Update Guild",

View File

@@ -70,5 +70,7 @@
"beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. 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."
}

View File

@@ -6,9 +6,11 @@
"welcomeTo": "Welcome to",
"welcomeBack": "Welcome back!",
"justin": "Justin",
"justinIntroMessage1": "Hello there! You must be new here. My name is Justin, your guide to Habitica.",
"justinIntroMessage1": "Hello there! You must be new here. My name is <strong>Justin</strong>, and I'll be your guide in Habitica.",
"justinIntroMessage2": "To start, you'll need to create an avatar.",
"justinIntroMessage3": "Great! Now, what are you interested in working on throughout this journey?",
"justinIntroMessageUsername": "Before we begin, lets figure out what to call you. Below youll find a display name and username Ive generated for you. After youve picked a display name and username, well get started by creating an avatar!",
"justinIntroMessageAppearance": "So how would you like to look? Dont worry, you can change this later.",
"introTour": "Here we are! I've filled out some Tasks for you based on your interests, so you can get started right away. Click a Task to edit or add new Tasks to fit your routine!",
"prev": "Prev",
"next": "Next",

View File

@@ -201,9 +201,10 @@
"usernameIssueInvalidCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.",
"currentUsername": "Current username:",
"displaynameIssueLength": "Display Names must be between 1 and 30 characters.",
"displaynameIssueSlur": "Display Names may not contain inappropriate language",
"displaynameIssueSlur": "Display Names may not contain inappropriate language.",
"goToSettings": "Go to Settings",
"usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!",
"usernameNotVerified": "Please confirm your username.",
"changeUsernameDisclaimer": "We will be transitioning login names to unique, public usernames soon. This username will be used for invitations, @mentions in chat, and messaging."
"changeUsernameDisclaimer": "We will be transitioning login names to unique, public usernames soon. This username will be used for invitations, @mentions in chat, and messaging.",
"verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!"
}

View File

@@ -210,7 +210,7 @@
"haveCouponCode": "Do you have a coupon code?",
"subscriptionAlreadySubscribedLeadIn": "Thanks for subscribing!",
"subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to <a href='/user/settings/subscription'>User icon &gt; Settings &gt; Subscription</a>.",
"purchaseAll": "Purchase All",
"purchaseAll": "Purchase Set",
"gemsPurchaseNote": "Subscribers can buy gems for gold in the Market! For easy access, you can also pin the gem to your Rewards column.",
"gemsRemaining": "gems remaining",
"notEnoughGemsToBuy": "You are unable to buy that amount of gems"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -9,7 +9,6 @@ import {
model as User,
nameFields,
} from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import {
NotFound,
BadRequest,
@@ -17,8 +16,11 @@ import {
} from '../../libs/errors';
import { removeFromArray } from '../../libs/collectionManipulators';
import { sendTxn as sendTxnEmail } from '../../libs/email';
import { encrypt } from '../../libs/encryption';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import {
inviteByUUID,
inviteByEmail,
inviteByUserName,
} from '../../libs/invites';
import common from '../../../common';
import payments from '../../libs/payments/payments';
import stripePayments from '../../libs/payments/stripe';
@@ -919,148 +921,6 @@ api.removeGroupMember = {
},
};
async function _inviteByUUID (uuid, group, inviter, req, res) {
let userToInvite = await User.findById(uuid).exec();
const publicGuild = group.type === 'guild' && group.privacy === 'public';
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
} else if (inviter._id === userToInvite._id) {
throw new BadRequest(res.t('cannotInviteSelfToGroup'));
}
const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite);
if (objections.length > 0) {
throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name}));
}
if (group.type === 'guild') {
if (_.includes(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name}));
}
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name}));
}
let guildInvite = {
id: group._id,
name: group.name,
inviter: inviter._id,
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite);
} else if (group.type === 'party') {
// Do not add to invitations.parties array if the user is already invited to that party
if (_.find(userToInvite.invitations.parties, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name}));
}
if (userToInvite.party._id) {
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name}));
}
let partyInvite = {id: group._id, name: group.name, inviter: inviter._id};
if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true;
userToInvite.invitations.parties.push(partyInvite);
userToInvite.invitations.party = partyInvite;
}
let groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
let groupTemplate = group.type === 'guild' ? 'guild' : 'party';
if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) {
let emailVars = [
{name: 'INVITER', content: inviter.profile.name},
];
if (group.type === 'guild') {
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: '/groups/discovery'}
);
} else {
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: '/party'}
);
}
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
}
if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) {
let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty';
sendPushNotification(
userToInvite,
{
title: group.name,
message: res.t(identifier),
identifier,
payload: {groupID: group._id, publicGuild},
}
);
}
let userInvited = await userToInvite.save();
if (group.type === 'guild') {
return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
} else if (group.type === 'party') {
return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1];
}
}
async function _inviteByEmail (invite, group, inviter, req, res) {
let userReturnInfo;
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
if (userToContact) {
userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
} else {
userReturnInfo = invite.email;
let cancelledPlan = false;
if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true;
const groupQueryString = JSON.stringify({
id: group._id,
inviter: inviter._id,
publicGuild: group.type === 'guild' && group.privacy === 'public',
sentAt: Date.now(), // so we can let it expire
cancelledPlan,
});
let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`;
let variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || inviter.profile.name},
];
if (group.type === 'guild') {
variables.push({name: 'GUILD_NAME', content: group.name});
}
// Check for the email address not to be unsubscribed
let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
let groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
}
return userReturnInfo;
}
/**
* @api {post} /api/v3/groups/:groupId/invite Invite users to a group
* @apiName InviteToGroup
@@ -1147,7 +1007,7 @@ api.inviteToGroup = {
url: '/groups/:groupId/invite',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
const user = res.locals.user;
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted'));
@@ -1155,35 +1015,48 @@ api.inviteToGroup = {
if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL }));
let validationErrors = req.validationErrors();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
const group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan'));
let uuids = req.body.uuids;
let emails = req.body.emails;
const {
uuids,
emails,
usernames,
} = req.body;
await Group.validateInvitations(uuids, emails, res, group);
await Group.validateInvitations({
uuids,
emails,
usernames,
}, res, group);
let results = [];
const results = [];
if (uuids) {
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
let uuidResults = await Promise.all(uuidInvites);
const uuidInvites = uuids.map((uuid) => inviteByUUID(uuid, group, user, req, res));
const uuidResults = await Promise.all(uuidInvites);
results.push(...uuidResults);
}
if (emails) {
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
const emailInvites = emails.map((invite) => inviteByEmail(invite, group, user, req, res));
user.invitesSent += emails.length;
await user.save();
let emailResults = await Promise.all(emailInvites);
const emailResults = await Promise.all(emailInvites);
results.push(...emailResults);
}
if (usernames) {
const usernameInvites = usernames.map((username) => inviteByUserName(username, group, user, req, res));
const usernameResults = await Promise.all(usernameInvites);
results.push(...usernameResults);
}
let analyticsObject = {
uuid: user._id,
hitType: 'event',

View File

@@ -109,6 +109,36 @@ api.getMember = {
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
if (!member.flags.verifiedUsername) member.auth.local.username = null;
// manually call toJSON with minimize: true so empty paths aren't returned
let memberToJSON = member.toJSON({minimize: true});
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
res.respond(200, memberToJSON);
},
};
api.getMemberByUsername = {
method: 'GET',
url: '/members/username/:username',
middlewares: [],
async handler (req, res) {
req.checkParams('username', res.t('invalidReqParams')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let username = req.params.username.toLowerCase();
if (username[0] === '@') username = username.slice(1, username.length);
let member = await User
.findOne({'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true})
.select(memberFields)
.exec();
if (!member) throw new NotFound(res.t('userNotFound'));
// manually call toJSON with minimize: true so empty paths aren't returned
let memberToJSON = member.toJSON({minimize: true});
User.addComputedStatsToJSONObj(memberToJSON.stats, member);
@@ -605,6 +635,7 @@ api.sendPrivateMessage = {
const message = req.body.message;
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.isAdmin()) throw new NotAuthorized(res.t(objections[0]));

View File

@@ -28,12 +28,14 @@ api.verifyUsername = {
const issues = verifyUsername(chosenUsername, res);
const existingUser = await User.findOne({
'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(),
}, {auth: 1}).exec();
if (issues.length < 1) {
const existingUser = await User.findOne({
'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(),
}, {auth: 1}).exec();
if (existingUser) {
if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken'));
if (existingUser) {
if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken'));
}
}
if (issues.length > 0) {

View File

@@ -1,5 +1,6 @@
import { authWithHeaders } from '../../middlewares/auth';
import * as userLib from '../../libs/user';
import { verifyDisplayName } from '../../libs/user/validation';
const api = {};
@@ -206,4 +207,32 @@ api.userReset = {
},
};
api.verifyDisplayName = {
method: 'POST',
url: '/user/auth/verify-display-name',
middlewares: [authWithHeaders({
optional: true,
})],
async handler (req, res) {
req.checkBody({
displayName: {
notEmpty: {errorMessage: res.t('messageMissingDisplayName')},
},
});
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const chosenDisplayName = req.body.displayName;
const issues = verifyDisplayName(chosenDisplayName, res);
if (issues.length > 0) {
res.respond(200, { isUsable: false, issues });
} else {
res.respond(200, { isUsable: true });
}
},
};
module.exports = api;

View File

@@ -79,8 +79,8 @@ async function registerLocal (req, res, { isV3 = false }) {
notEmpty: true,
errorMessage: res.t('missingUsername'),
// TODO use the constants in the error message above
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')},
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameIssueLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameIssueInvalidCharacters')},
},
email: {
notEmpty: true,
@@ -138,6 +138,9 @@ async function registerLocal (req, res, { isV3 = false }) {
preferences: {
language: req.language,
},
flags: {
verifiedUsername: true,
},
};
if (existingUser) {
@@ -159,8 +162,6 @@ async function registerLocal (req, res, { isV3 = false }) {
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
}
newUser.flags.verifiedUsername = true;
let savedUser = await newUser.save();
let userToJSON;

View File

@@ -61,6 +61,9 @@ async function loginSocial (req, res) {
preferences: {
language: req.language,
},
flags: {
verifiedUsername: true,
},
};
if (existingUser) {

View File

@@ -0,0 +1,211 @@
import _ from 'lodash';
import { encrypt } from '../encryption';
import { sendNotification as sendPushNotification } from '../pushNotifications';
import {
NotFound,
BadRequest,
NotAuthorized,
} from '../errors';
import { sendTxn as sendTxnEmail } from '../email';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import {
model as User,
} from '../../models/user';
import {
model as Group,
} from '../../models/group';
function sendInvitePushNotification (userToInvite, groupLabel, group, publicGuild, res) {
if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] === false) return;
const identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty';
sendPushNotification(
userToInvite,
{
title: group.name,
message: res.t(identifier),
identifier,
payload: {groupID: group._id, publicGuild},
}
);
}
function sendInviteEmail (userToInvite, groupLabel, group, inviter) {
if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] === false) return;
const groupTemplate = group.type === 'guild' ? 'guild' : 'party';
const emailVars = [
{name: 'INVITER', content: inviter.profile.name},
];
if (group.type === 'guild') {
emailVars.push(
{name: 'GUILD_NAME', content: group.name},
{name: 'GUILD_URL', content: '/groups/discovery'}
);
} else {
emailVars.push(
{name: 'PARTY_NAME', content: group.name},
{name: 'PARTY_URL', content: '/party'}
);
}
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
}
function inviteUserToGuild (userToInvite, group, inviter, publicGuild, res) {
const uuid = userToInvite._id;
if (_.includes(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name}));
}
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name}));
}
const guildInvite = {
id: group._id,
name: group.name,
inviter: inviter._id,
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite);
}
async function inviteUserToParty (userToInvite, group, inviter, res) {
const uuid = userToInvite._id;
// Do not add to invitations.parties array if the user is already invited to that party
if (_.find(userToInvite.invitations.parties, {id: group._id})) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name}));
}
if (userToInvite.party._id) {
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name}));
}
let partyInvite = {id: group._id, name: group.name, inviter: inviter._id};
if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true;
userToInvite.invitations.parties.push(partyInvite);
userToInvite.invitations.party = partyInvite;
}
async function addInvitationToUser (userToInvite, group, inviter, res) {
const publicGuild = group.type === 'guild' && group.privacy === 'public';
if (group.type === 'guild') {
inviteUserToGuild(userToInvite, group, inviter, publicGuild, res);
} else if (group.type === 'party') {
await inviteUserToParty(userToInvite, group, inviter, res);
}
const groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
sendInviteEmail(userToInvite, groupLabel, group, inviter);
sendInvitePushNotification(userToInvite, groupLabel, group, publicGuild, res);
const userInvited = await userToInvite.save();
if (group.type === 'guild') {
return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
}
if (group.type === 'party') {
return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1];
}
}
async function inviteByUUID (uuid, group, inviter, req, res) {
const userToInvite = await User.findById(uuid).exec();
if (!userToInvite) {
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
} else if (inviter._id === userToInvite._id) {
throw new BadRequest(res.t('cannotInviteSelfToGroup'));
}
const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite);
if (objections.length > 0) {
throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name}));
}
return await addInvitationToUser(userToInvite, group, inviter, res);
}
async function inviteByEmail (invite, group, inviter, req, res) {
let userReturnInfo;
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
let userToContact = await User.findOne({$or: [
{'auth.local.email': invite.email},
{'auth.facebook.emails.value': invite.email},
{'auth.google.emails.value': invite.email},
]})
.select({_id: true, 'preferences.emailNotifications': true})
.exec();
if (userToContact) {
userReturnInfo = await inviteByUUID(userToContact._id, group, inviter, req, res);
} else {
userReturnInfo = invite.email;
let cancelledPlan = false;
if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true;
const groupQueryString = JSON.stringify({
id: group._id,
inviter: inviter._id,
publicGuild: group.type === 'guild' && group.privacy === 'public',
sentAt: Date.now(), // so we can let it expire
cancelledPlan,
});
let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`;
let variables = [
{name: 'LINK', content: link},
{name: 'INVITER', content: req.body.inviter || inviter.profile.name},
];
if (group.type === 'guild') {
variables.push({name: 'GUILD_NAME', content: group.name});
}
// Check for the email address not to be unsubscribed
let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
let groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
}
return userReturnInfo;
}
async function inviteByUserName (username, group, inviter, req, res) {
if (username.indexOf('@') === 0) username = username.slice(1, username.length);
username = username.toLowerCase();
const userToInvite = await User.findOne({'auth.local.lowerCaseUsername': username}).exec();
if (!userToInvite) {
throw new NotFound(res.t('userWithUsernameNotFound', { username }));
}
if (inviter._id === userToInvite._id) {
throw new BadRequest(res.t('cannotInviteSelfToGroup'));
}
return await addInvitationToUser(userToInvite, group, inviter, res);
}
module.exports = {
inviteByUUID,
inviteByEmail,
inviteByUserName,
};

View File

@@ -26,6 +26,14 @@ function usernameContainsInvalidCharacters (username) {
return match !== null && match[0] !== null;
}
export function verifyDisplayName (displayName, res) {
let issues = [];
if (displayName.length < 1 || displayName.length > 30) issues.push(res.t('displaynameIssueLength'));
if (nameContainsSlur(displayName)) issues.push(res.t('displaynameIssueSlur'));
return issues;
}
export function verifyUsername (username, res) {
let issues = [];
if (username.length < 1 || username.length > 20) issues.push(res.t('usernameIssueLength'));

View File

@@ -350,40 +350,32 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
return toJSON;
};
/**
* Checks invitation uuids and emails for possible errors.
*
* @param uuids An array of user ids
* @param emails An array of emails
* @param res Express res object for use with translations
* @throws BadRequest An error describing the issue with the invitations
*/
schema.statics.validateInvitations = async function getInvitationError (uuids, emails, res, group = null) {
let uuidsIsArray = Array.isArray(uuids);
let emailsIsArray = Array.isArray(emails);
let emptyEmails = emailsIsArray && emails.length < 1;
let emptyUuids = uuidsIsArray && uuids.length < 1;
function getInviteError (uuids, emails, usernames) {
const uuidsIsArray = Array.isArray(uuids);
const emailsIsArray = Array.isArray(emails);
const usernamesIsArray = Array.isArray(usernames);
const emptyEmails = emailsIsArray && emails.length < 1;
const emptyUuids = uuidsIsArray && uuids.length < 1;
const emptyUsernames = usernamesIsArray && usernames.length < 1;
let errorString;
if (!uuids && !emails) {
if (!uuids && !emails && !usernames) {
errorString = 'canOnlyInviteEmailUuid';
} else if (uuids && !uuidsIsArray) {
errorString = 'uuidsMustBeAnArray';
} else if (emails && !emailsIsArray) {
errorString = 'emailsMustBeAnArray';
} else if (!emails && emptyUuids) {
errorString = 'inviteMissingUuid';
} else if (!uuids && emptyEmails) {
errorString = 'inviteMissingEmail';
} else if (emptyEmails && emptyUuids) {
} else if (usernames && !usernamesIsArray) {
errorString = 'usernamesMustBeAnArray';
} else if ((!emails || emptyEmails) && (!uuids || emptyUuids) && (!usernames || emptyUsernames)) {
errorString = 'inviteMustNotBeEmpty';
}
if (errorString) {
throw new BadRequest(res.t(errorString));
}
return errorString;
}
function getInviteCount (uuids, emails) {
let totalInvites = 0;
if (uuids) {
@@ -394,6 +386,27 @@ schema.statics.validateInvitations = async function getInvitationError (uuids, e
totalInvites += emails.length;
}
return totalInvites;
}
/**
* Checks invitation uuids and emails for possible errors.
*
* @param uuids An array of user ids
* @param emails An array of emails
* @param res Express res object for use with translations
* @throws BadRequest An error describing the issue with the invitations
*/
schema.statics.validateInvitations = async function getInvitationError (invites, res, group = null) {
const {
uuids,
emails,
usernames,
} = invites;
const errorString = getInviteError(uuids, emails, usernames);
if (errorString) throw new BadRequest(res.t(errorString));
const totalInvites = getInviteCount(uuids, emails);
if (totalInvites > INVITES_LIMIT) {
throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
}

View File

@@ -9,7 +9,8 @@ const defaultSchema = () => ({
text: String,
// sender properties
user: String, // profile name
user: String, // profile name (unfortunately)
username: String,
contributor: {$type: mongoose.Schema.Types.Mixed},
backer: {$type: mongoose.Schema.Types.Mixed},
uuid: String, // sender uuid
@@ -117,6 +118,7 @@ export function messageDefaults (msg, user) {
contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(),
user: user.profile.name,
username: user.flags && user.flags.verifiedUsername && user.auth && user.auth.local && user.auth.local.username,
});
} else {
message.uuid = 'system';

View File

@@ -8,10 +8,11 @@ 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 preferences.disableClasses profile stats
achievements party backer contributor auth.timestamps items inbox.optOut loginIncentives flags.classSelected`;
achievements party backer contributor auth.timestamps items inbox.optOut loginIncentives flags.classSelected
flags.verifiedUsername auth.local.username`;
// The minimum amount of data needed when populating multiple users
export let nameFields = 'profile.name';
export let nameFields = 'profile.name auth.local.username flags.verifiedUsername';
export { schema };

View File

@@ -219,6 +219,9 @@ schema.statics.transformJSONUser = function transformJSONUser (jsonUser, addComp
// Add id property
jsonUser.id = jsonUser._id;
// Remove username if not verified
if (!jsonUser.flags.verifiedUsername) jsonUser.auth.local.username = null;
if (addComputedStats) this.addComputedStatsToJSONObj(jsonUser.stats, jsonUser);
};