Merge branch 'release' into sabrecat/timetrav-bgs

This commit is contained in:
Sabe Jones
2020-01-21 14:58:51 -06:00
311 changed files with 13085 additions and 5455 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ yarn.lock
.elasticbeanstalk/* .elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml !.elasticbeanstalk/*.global.yml
# webstorm fake webpack for path intellisense
webpack.webstorm.config

View File

@@ -3,9 +3,12 @@ FROM node:12
# Install global packages # Install global packages
RUN npm install -g gulp-cli mocha RUN npm install -g gulp-cli mocha
# Copy Habitica code into container and install dependencies # Copy package.json and package-lock.json into image, then install
# dependencies.
WORKDIR /usr/src/habitica WORKDIR /usr/src/habitica
COPY . /usr/src/habitica COPY ["package.json", "package-lock.json", "./"]
RUN npm install RUN npm install
# Copy the remaining source files in.
COPY . /usr/src/habitica
RUN npm run postinstall RUN npm run postinstall

View File

@@ -15,8 +15,9 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- .:/code - .:/usr/src/habitica
- /code/node_modules - /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server: server:
build: build:
context: . context: .
@@ -32,8 +33,8 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- .:/code - .:/usr/src/habitica
- /code/node_modules - /usr/src/habitica/node_modules
mongo: mongo:
image: mongo:3.6 image: mongo:3.6
networks: networks:

View File

@@ -50,6 +50,7 @@ async function updateUser (user) {
set['achievements.purchasedEquipment'] = true; set['achievements.purchasedEquipment'] = true;
} }
if (user.tasksOrder) {
const hasTask = Object.keys(user.tasksOrder).find(tasksOrderType => { const hasTask = Object.keys(user.tasksOrder).find(tasksOrderType => {
const order = user.tasksOrder[tasksOrderType]; const order = user.tasksOrder[tasksOrderType];
@@ -65,6 +66,7 @@ async function updateUser (user) {
if (hasTask && hasExperience) { if (hasTask && hasExperience) {
set['achievements.completedTask'] = true; set['achievements.completedTask'] = true;
} }
}
if (count % progressCount === 0) console.warn(`${count} ${user._id}`); if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
return User.update({ _id: user._id }, { $set: set }).exec(); return User.update({ _id: user._id }, { $set: set }).exec();

View File

@@ -27,7 +27,7 @@ function uploadFile (buffer, fileName) {
if (error) { if (error) {
reject(error); reject(error);
} else { } else {
// console.info(`${fileName} uploaded to ${BUCKET_NAME} succesfully.`); // console.info(`${fileName} uploaded to ${BUCKET_NAME} successfully.`);
resolve(fileName); resolve(fileName);
} }
}); });

1468
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,29 @@
{ {
"name": "habitica", "name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.", "description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "4.129.1", "version": "4.129.4",
"main": "./website/server/index.js", "main": "./website/server/index.js",
"dependencies": { "dependencies": {
"@babel/core": "^7.7.5", "@babel/core": "^7.8.0",
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.8.2",
"@babel/register": "^7.7.4", "@babel/register": "^7.8.0",
"@google-cloud/trace-agent": "^4.2.4", "@google-cloud/trace-agent": "^4.2.5",
"@slack/client": "^3.8.1", "@slack/client": "^3.8.1",
"accepts": "^1.3.5", "accepts": "^1.3.5",
"amazon-payments": "^0.2.7", "amazon-payments": "^0.2.8",
"amplitude": "^3.5.0", "amplitude": "^3.5.0",
"apidoc": "^0.17.5", "apidoc": "^0.17.5",
"apn": "^2.2.0", "apn": "^2.2.0",
"aws-sdk": "^2.590.0", "aws-sdk": "^2.601.0",
"bcrypt": "^3.0.7", "bcrypt": "^3.0.7",
"body-parser": "^1.18.3", "body-parser": "^1.18.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"cookie-session": "^1.3.3", "cookie-session": "^1.4.0",
"coupon-code": "^0.4.5", "coupon-code": "^0.4.5",
"csv-stringify": "^5.3.4", "csv-stringify": "^5.3.6",
"cwait": "^1.1.1", "cwait": "^1.1.1",
"domain-middleware": "~0.1.0", "domain-middleware": "~0.1.0",
"eslint": "^6.7.2", "eslint": "^6.8.0",
"eslint-config-habitrpg": "^6.2.0", "eslint-config-habitrpg": "^6.2.0",
"eslint-plugin-mocha": "^5.0.0", "eslint-plugin-mocha": "^5.0.0",
"express": "^4.16.3", "express": "^4.16.3",
@@ -46,7 +46,7 @@
"method-override": "^3.0.0", "method-override": "^3.0.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-recur": "^1.0.7", "moment-recur": "^1.0.7",
"mongoose": "^5.8.1", "mongoose": "^5.8.7",
"morgan": "^1.7.0", "morgan": "^1.7.0",
"nconf": "^0.10.0", "nconf": "^0.10.0",
"node-gcm": "^1.0.2", "node-gcm": "^1.0.2",
@@ -62,8 +62,8 @@
"remove-markdown": "^0.3.0", "remove-markdown": "^0.3.0",
"rimraf": "^3.0.0", "rimraf": "^3.0.0",
"short-uuid": "^3.0.0", "short-uuid": "^3.0.0",
"stripe": "^7.14.0", "stripe": "^7.15.0",
"superagent": "^5.1.2", "superagent": "^5.2.1",
"universal-analytics": "^0.4.17", "universal-analytics": "^0.4.17",
"useragent": "^2.1.9", "useragent": "^2.1.9",
"uuid": "^3.3.3", "uuid": "^3.3.3",
@@ -71,7 +71,7 @@
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"winston": "^2.4.3", "winston": "^2.4.3",
"winston-loggly-bulk": "^2.0.2", "winston-loggly-bulk": "^2.0.2",
"xml2js": "^0.4.4" "xml2js": "^0.4.23"
}, },
"private": true, "private": true,
"engines": { "engines": {
@@ -103,17 +103,17 @@
"apidoc": "gulp apidoc" "apidoc": "gulp apidoc"
}, },
"devDependencies": { "devDependencies": {
"axios": "^0.19.0", "axios": "^0.19.1",
"chai": "^4.1.2", "chai": "^4.1.2",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"chalk": "^2.4.1", "chalk": "^2.4.1",
"expect.js": "^0.3.1", "expect.js": "^0.3.1",
"istanbul": "^1.1.0-alpha.1", "istanbul": "^1.1.0-alpha.1",
"mocha": "^5.1.1", "mocha": "^5.1.1",
"monk": "^7.1.1", "monk": "^7.1.2",
"require-again": "^2.0.0", "require-again": "^2.0.0",
"sinon": "^7.2.4", "sinon": "^7.2.4",
"sinon-chai": "^3.0.0", "sinon-chai": "^3.4.0",
"sinon-stub-promise": "^4.0.0" "sinon-stub-promise": "^4.0.0"
}, },
"optionalDependencies": {} "optionalDependencies": {}

View File

@@ -155,7 +155,7 @@ describe('analyticsService', () => {
}); });
}); });
it('sets Unkown if headers are not passed in', () => { it('sets Unknown if headers are not passed in', () => {
delete data.headers; delete data.headers;
return analyticsService.track(eventType, data) return analyticsService.track(eventType, data)
@@ -476,7 +476,7 @@ describe('analyticsService', () => {
}); });
}); });
it('sets Unkown if headers are not passed in', () => { it('sets Unknown if headers are not passed in', () => {
delete data.headers; delete data.headers;
return analyticsService.trackPurchase(data) return analyticsService.trackPurchase(data)

View File

@@ -626,7 +626,7 @@ describe('payments/index', () => {
.calledWith(recipient, { receiverMsg: msg, senderMsg: msg, save: false }); .calledWith(recipient, { receiverMsg: msg, senderMsg: msg, save: false });
}); });
it('sends a message from purchaser to recipient wtih custom message', async () => { it('sends a message from purchaser to recipient with custom message', async () => {
data.gift.message = 'giftmessage'; data.gift.message = 'giftmessage';
await api.buyGems(data); await api.buyGems(data);

View File

@@ -43,6 +43,7 @@ describe('webhooks', () => {
options: { options: {
questStarted: true, questStarted: true,
questFinised: true, questFinised: true,
questInvited: true,
}, },
}, { }, {
id: 'userActivity', id: 'userActivity',
@@ -576,7 +577,7 @@ describe('webhooks', () => {
}; };
}); });
['questStarted', 'questFinised'].forEach(type => { ['questStarted', 'questFinised', 'questInvited'].forEach(type => {
it(`sends ${type} webhooks`, () => { it(`sends ${type} webhooks`, () => {
data.type = type; data.type = type;

View File

@@ -183,6 +183,7 @@ describe('Webhook Model', () => {
options: { options: {
questStarted: true, questStarted: true,
questFinished: true, questFinished: true,
questInvited: true,
}, },
}; };
}); });
@@ -197,6 +198,7 @@ describe('Webhook Model', () => {
expect(wh.options).to.eql({ expect(wh.options).to.eql({
questStarted: false, questStarted: false,
questFinished: false, questFinished: false,
questInvited: false,
}); });
}); });
@@ -210,6 +212,7 @@ describe('Webhook Model', () => {
expect(wh.options).to.eql({ expect(wh.options).to.eql({
questStarted: false, questStarted: false,
questFinished: true, questFinished: true,
questInvited: true,
}); });
}); });
@@ -224,6 +227,7 @@ describe('Webhook Model', () => {
expect(wh.options).to.eql({ expect(wh.options).to.eql({
questStarted: true, questStarted: true,
questFinished: true, questFinished: true,
questInvited: true,
}); });
}); });

View File

@@ -251,7 +251,7 @@ describe('POST /challenges', () => {
expect(groupLeader.balance).to.eql(oldUserBalance); expect(groupLeader.balance).to.eql(oldUserBalance);
}); });
it('sets all properites of the challenge as passed', async () => { it('sets all properties of the challenge as passed', async () => {
const name = 'Test Challenge'; const name = 'Test Challenge';
const shortName = 'TC Label'; const shortName = 'TC Label';
const description = 'Test Description'; const description = 'Test Description';

View File

@@ -177,7 +177,7 @@ describe('GET /groups/:id', () => {
}); });
}); });
it('removes non-existant guild from user\'s guild list', async () => { it('removes non-existent guild from user\'s guild list', async () => {
const guildId = generateUUID(); const guildId = generateUUID();
await user.update({ await user.update({
@@ -197,7 +197,7 @@ describe('GET /groups/:id', () => {
expect(user.guilds).to.not.include(guildId); expect(user.guilds).to.not.include(guildId);
}); });
it('removes non-existant party from user\'s party object', async () => { it('removes non-existent party from user\'s party object', async () => {
const partyId = generateUUID(); const partyId = generateUUID();
await user.update({ await user.update({

View File

@@ -210,7 +210,7 @@ describe('POST /groups/:groupId/leave', () => {
expect(userWithoutInvitation.invitations.guilds).to.not.be.empty; expect(userWithoutInvitation.invitations.guilds).to.not.be.empty;
}); });
it('deletes non existant guild from user when user tries to leave', async () => { it('deletes non existent guild from user when user tries to leave', async () => {
const nonExistentGuildId = generateUUID(); const nonExistentGuildId = generateUUID();
const userWithNonExistentGuild = await generateUser({ guilds: [nonExistentGuildId] }); const userWithNonExistentGuild = await generateUser({ guilds: [nonExistentGuildId] });
expect(userWithNonExistentGuild.guilds).to.contain(nonExistentGuildId); expect(userWithNonExistentGuild.guilds).to.contain(nonExistentGuildId);
@@ -258,7 +258,7 @@ describe('POST /groups/:groupId/leave', () => {
}); });
}); });
it('deletes non existant party from user when user tries to leave', async () => { it('deletes non existent party from user when user tries to leave', async () => {
const nonExistentPartyId = generateUUID(); const nonExistentPartyId = generateUUID();
const userWithNonExistentParty = await generateUser({ 'party._id': nonExistentPartyId }); const userWithNonExistentParty = await generateUser({ 'party._id': nonExistentPartyId });
expect(userWithNonExistentParty.party._id).to.eql(nonExistentPartyId); expect(userWithNonExistentParty.party._id).to.eql(nonExistentPartyId);

View File

@@ -66,7 +66,7 @@ describe('POST /members/send-private-message', () => {
})).to.eventually.be.rejected.and.eql({ })).to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('notAuthorizedToSendMessageToThisUser'), message: t('blockedToSendToThisUser'),
}); });
}); });

View File

@@ -89,7 +89,7 @@ describe('POST /members/transfer-gems', () => {
})).to.eventually.be.rejected.and.eql({ })).to.eventually.be.rejected.and.eql({
code: 401, code: 401,
error: 'NotAuthorized', error: 'NotAuthorized',
message: t('notAuthorizedToSendMessageToThisUser'), message: t('blockedToSendToThisUser'),
}); });
}); });

View File

@@ -29,7 +29,7 @@ describe('payments - amazon - #checkout', () => {
amzLib.checkout.restore(); amzLib.checkout.restore();
}); });
it('makes a purcahse with amazon checkout', async () => { it('makes a purchase with amazon checkout', async () => {
user = await generateUser({ user = await generateUser({
'profile.name': 'sender', 'profile.name': 'sender',
'purchased.plan.customerId': 'customer-id', 'purchased.plan.customerId': 'customer-id',

View File

@@ -2,6 +2,7 @@ import { v4 as generateUUID } from 'uuid';
import { import {
createAndPopulateGroup, createAndPopulateGroup,
translate as t, translate as t,
server,
sleep, sleep,
} from '../../../../helpers/api-integration/v3'; } from '../../../../helpers/api-integration/v3';
import { quests as questScrolls } from '../../../../../website/common/script/content/quests'; import { quests as questScrolls } from '../../../../../website/common/script/content/quests';
@@ -210,5 +211,39 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => {
const returnedGroup = await groupLeader.get(`/groups/${group._id}`); const returnedGroup = await groupLeader.get(`/groups/${group._id}`);
expect(returnedGroup.chat[0]._meta).to.be.undefined; expect(returnedGroup.chat[0]._meta).to.be.undefined;
}); });
context('sending quest activity webhooks', () => {
before(async () => {
await server.start();
});
after(async () => {
await server.close();
});
it('sends quest invited webhook', async () => {
const uuid = generateUUID();
await member.post('/user/webhook', {
url: `http://localhost:${server.port}/webhooks/${uuid}`,
type: 'questActivity',
enabled: true,
options: {
questInvited: true,
},
});
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await sleep();
const body = server.getWebhookData(uuid);
expect(body.type).to.eql('questInvited');
expect(body.group.id).to.eql(questingGroup.id);
expect(body.group.name).to.eql(questingGroup.name);
expect(body.quest.key).to.eql(PET_QUEST);
});
});
}); });
}); });

View File

@@ -120,7 +120,7 @@ describe('DELETE /tasks/:id', () => {
}); });
context('task cannot be deleted', () => { context('task cannot be deleted', () => {
it('cannot delete a non-existant task', async () => { it('cannot delete a non-existent task', async () => {
await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({
code: 404, code: 404,
error: 'NotFound', error: 'NotFound',

View File

@@ -39,7 +39,7 @@ describe('GET /tasks/:id', () => {
}); });
context('task cannot be accessed', () => { context('task cannot be accessed', () => {
it('cannot get a non-existant task', async () => { it('cannot get a non-existent task', async () => {
const dummyId = generateUUID(); const dummyId = generateUUID();
await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({

View File

@@ -27,7 +27,7 @@ describe('DELETE /tasks/:id', () => {
}); });
}); });
it('cannot delete a non-existant task', async () => { it('cannot delete a non-existent task', async () => {
await expect(user.del(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ await expect(user.del(`/tasks/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
code: 404, code: 404,
error: 'NotFound', error: 'NotFound',

View File

@@ -16,7 +16,7 @@ describe('PUT /user/auth/update-email', () => {
const newEmail = 'SOmE-nEw-emAIl_2@example.net'; const newEmail = 'SOmE-nEw-emAIl_2@example.net';
const oldPassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js const oldPassword = 'password'; // from habitrpg/test/helpers/api-integration/v3/object-generators.js
context('Local Authenticaion User', async () => { context('Local Authentication User', async () => {
let user; let user;
beforeEach(async () => { beforeEach(async () => {

View File

@@ -226,6 +226,69 @@ describe('POST /user/webhook', () => {
}); });
}); });
it('defaults questActivity options', async () => {
body.type = 'questActivity';
const webhook = await user.post('/user/webhook', body);
expect(webhook.options).to.eql({
questStarted: false,
questFinished: false,
questInvited: false,
});
});
it('can set questActivity options', async () => {
body.type = 'questActivity';
body.options = {
questStarted: true,
questFinished: true,
questInvited: true,
};
const webhook = await user.post('/user/webhook', body);
expect(webhook.options).to.eql({
questStarted: true,
questFinished: true,
questInvited: true,
});
});
it('discards extra properties in questActivity options', async () => {
body.type = 'questActivity';
body.options = {
questStarted: false,
questFinished: true,
questInvited: true,
foo: 'bar',
};
const webhook = await user.post('/user/webhook', body);
expect(webhook.options.foo).to.not.exist;
expect(webhook.options).to.eql({
questStarted: false,
questFinished: true,
questInvited: true,
});
});
['questStarted', 'questFinished', 'questInvited'].forEach(option => {
it(`requires questActivity option ${option} to be a boolean`, async () => {
body.type = 'questActivity';
body.options = {
[option]: 'not a boolean',
};
await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('webhookBooleanOption', { option }),
});
});
});
it('discards extra properties in globalActivity options', async () => { it('discards extra properties in globalActivity options', async () => {
body.type = 'globalActivity'; body.type = 'globalActivity';
body.options = { body.options = {

View File

@@ -15,8 +15,8 @@ describe('getDebuffPotionItems', () => {
for (const key of Object.keys(TRANSFORMATION_DEBUFFS_LIST)) { for (const key of Object.keys(TRANSFORMATION_DEBUFFS_LIST)) {
const debuff = TRANSFORMATION_DEBUFFS_LIST[key]; const debuff = TRANSFORMATION_DEBUFFS_LIST[key];
// Here we itterate whole object to dynamicaly create test suites as // Here we iterate the whole object to dynamically create test suites as
// it described in dock of mocha // described in mocha's docs
// https://mochajs.org/#dynamically-generating-tests // https://mochajs.org/#dynamically-generating-tests
// That's why we have eslint-disable here // That's why we have eslint-disable here
// eslint-disable-next-line no-loop-func // eslint-disable-next-line no-loop-func

View File

@@ -54,7 +54,7 @@ describe('shared.ops.unlock', () => {
} }
}); });
// disabled untill fully implemente // disabled until fully implemente
xit('returns an error when user already owns items in a full set', done => { xit('returns an error when user already owns items in a full set', done => {
try { try {
unlock(user, { query: { path: unlockPath } }); unlock(user, { query: { path: unlockPath } });

View File

@@ -1,6 +1,37 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { configure } from '@storybook/vue'; import { configure } from '@storybook/vue';
import '../../src/assets/scss/index.scss'; import '../../src/assets/scss/index.scss';
import '../../src/assets/css/sprites.css';
import '../../src/assets/css/sprites/spritesmith-main-0.css';
import '../../src/assets/css/sprites/spritesmith-main-1.css';
import '../../src/assets/css/sprites/spritesmith-main-2.css';
import '../../src/assets/css/sprites/spritesmith-main-3.css';
import '../../src/assets/css/sprites/spritesmith-main-4.css';
import '../../src/assets/css/sprites/spritesmith-main-5.css';
import '../../src/assets/css/sprites/spritesmith-main-6.css';
import '../../src/assets/css/sprites/spritesmith-main-7.css';
import '../../src/assets/css/sprites/spritesmith-main-8.css';
import '../../src/assets/css/sprites/spritesmith-main-9.css';
import '../../src/assets/css/sprites/spritesmith-main-10.css';
import '../../src/assets/css/sprites/spritesmith-main-11.css';
import '../../src/assets/css/sprites/spritesmith-main-12.css';
import '../../src/assets/css/sprites/spritesmith-main-13.css';
import '../../src/assets/css/sprites/spritesmith-main-14.css';
import '../../src/assets/css/sprites/spritesmith-main-15.css';
import '../../src/assets/css/sprites/spritesmith-main-16.css';
import '../../src/assets/css/sprites/spritesmith-main-17.css';
import '../../src/assets/css/sprites/spritesmith-main-18.css';
import '../../src/assets/css/sprites/spritesmith-main-19.css';
import '../../src/assets/css/sprites/spritesmith-main-20.css';
import '../../src/assets/css/sprites/spritesmith-main-21.css';
import '../../src/assets/css/sprites/spritesmith-main-22.css';
import '../../src/assets/css/sprites/spritesmith-main-23.css';
import '../../src/assets/css/sprites/spritesmith-main-24.css';
import '../../src/assets/css/sprites/spritesmith-main-25.css';
import '../../src/assets/css/sprites/spritesmith-main-26.css';
import Vue from 'vue';
import StoreModule from '@/libs/store';
const req = require.context('../../src', true, /.stories.js$/); const req = require.context('../../src', true, /.stories.js$/);
@@ -8,4 +39,6 @@ function loadStories () {
req.keys().forEach(filename => req(filename)); req.keys().forEach(filename => req(filename));
} }
Vue.use(StoreModule);
configure(loadStories, module); configure(loadStories, module);

View File

@@ -0,0 +1,75 @@
export const userStyles = {
contributor: {
admin: true,
level: 9,
text: '',
},
items: {
gear: {
equipped: {
armor: 'armor_special_2',
head: 'head_special_2',
shield: 'shield_special_goldenknight',
headAccessory: 'headAccessory_base_0',
eyewear: 'eyewear_base_0',
weapon: 'weapon_special_1',
back: 'back_base_0',
},
costume: {
armor: 'armor_special_fallRogue',
head: 'head_special_fallRogue',
shield: 'shield_armoire_shieldOfDiamonds',
body: 'body_mystery_201706',
eyewear: 'eyewear_special_blackHalfMoon',
back: 'back_base_0',
headAccessory: 'headAccessory_special_wolfEars',
weapon: 'weapon_armoire_lamplighter',
},
},
},
preferences: {
hair: {
color: 'black', base: 0, bangs: 3, beard: 0, mustache: 0, flower: 0,
},
tasks: { groupByChallenge: false, confirmScoreNotes: false },
size: 'broad',
skin: 'wolf',
shirt: 'zombie',
chair: 'none',
sleep: true,
disableClasses: false,
background: 'midnight_castle',
costume: true,
},
stats: {
buffs: {
str: 0,
int: 0,
per: 0,
con: 0,
stealth: 0,
streaks: false,
snowball: false,
spookySparkles: false,
shinySeed: false,
seafoam: false,
},
training: {
int: 0, per: 0, str: 0, con: 0,
},
hp: 50,
mp: 158,
exp: 227,
gp: 464.31937261345155,
lvl: 17,
class: 'rogue',
points: 17,
str: 0,
con: 0,
int: 0,
per: 0,
toNextLevel: 380,
maxHealth: 50,
maxMP: 158,
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -13,39 +13,39 @@
"test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js" "test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js"
}, },
"dependencies": { "dependencies": {
"@vue/cli-plugin-babel": "^4.1.1", "@vue/cli-plugin-babel": "^4.1.2",
"@vue/cli-plugin-eslint": "^4.1.1", "@vue/cli-plugin-eslint": "^4.1.2",
"@vue/cli-plugin-router": "^4.1.1", "@vue/cli-plugin-router": "^4.1.2",
"@vue/cli-plugin-unit-mocha": "^4.1.1", "@vue/cli-plugin-unit-mocha": "^4.1.2",
"@vue/cli-service": "^4.1.1", "@vue/cli-service": "^4.1.2",
"@storybook/addon-actions": "^5.0.0", "@storybook/addon-actions": "^5.3.1",
"@storybook/addon-knobs": "^5.0.0", "@storybook/addon-knobs": "^5.3.1",
"@storybook/addon-links": "^5.0.0", "@storybook/addon-links": "^5.3.1",
"@storybook/addon-notes": "^5.0.0", "@storybook/addon-notes": "^5.3.1",
"@storybook/vue": "^5.2.5", "@storybook/vue": "^5.3.1",
"@vue/test-utils": "1.0.0-beta.29", "@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^5.8.0", "amplitude-js": "^5.8.0",
"axios": "^0.19.0", "axios": "^0.19.1",
"axios-progress-bar": "^1.2.0", "axios-progress-bar": "^1.2.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"bootstrap": "^4.4.1", "bootstrap": "^4.4.1",
"bootstrap-vue": "^2.1.0", "bootstrap-vue": "^2.2.0",
"chai": "^4.1.2", "chai": "^4.1.2",
"core-js": "^3.5.0", "core-js": "^3.6.3",
"eslint": "^6.7.2", "eslint": "^6.8.0",
"eslint-config-habitrpg": "^6.2.0", "eslint-config-habitrpg": "^6.2.0",
"eslint-plugin-mocha": "^5.3.0", "eslint-plugin-mocha": "^5.3.0",
"eslint-plugin-vue": "^6.0.1", "eslint-plugin-vue": "^6.1.2",
"habitica-markdown": "^1.3.2", "habitica-markdown": "^1.3.2",
"hellojs": "^1.18.1", "hellojs": "^1.18.4",
"inspectpack": "^4.2.2", "inspectpack": "^4.3.0",
"intro.js": "^2.9.3", "intro.js": "^2.9.3",
"jquery": "^3.4.1", "jquery": "^3.4.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"moment": "^2.24.0", "moment": "^2.24.0",
"nconf": "^0.10.0", "nconf": "^0.10.0",
"sass": "^1.23.7", "sass": "^1.24.4",
"sass-loader": "^8.0.0", "sass-loader": "^8.0.1",
"smartbanner.js": "^1.15.0", "smartbanner.js": "^1.15.0",
"svg-inline-loader": "^0.8.0", "svg-inline-loader": "^0.8.0",
"svg-url-loader": "^3.0.3", "svg-url-loader": "^3.0.3",
@@ -58,8 +58,9 @@
"vue-mugen-scroll": "^0.2.6", "vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.0.6", "vue-router": "^3.0.6",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"vue2-perfect-scrollbar": "^1.3.0",
"vuedraggable": "^2.23.1", "vuedraggable": "^2.23.1",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec", "vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
"webpack": "^4.41.2" "webpack": "^4.41.5"
} }
} }

View File

@@ -28,7 +28,10 @@
</div> </div>
<div <div
id="app" id="app"
:class="{'casting-spell': castingSpell}" :class="{
'casting-spell': castingSpell,
'resting': showRestingBanner
}"
> >
<banned-account-modal /> <banned-account-modal />
<amazon-payments-modal v-if="!isStaticPage" /> <amazon-payments-modal v-if="!isStaticPage" />
@@ -66,7 +69,10 @@
</div> </div>
<notifications-display /> <notifications-display />
<app-menu /> <app-menu />
<div class="container-fluid"> <div
class="container-fluid"
:class="{'no-margin': noMargin}"
>
<app-header /> <app-header />
<buyModal <buyModal
:item="selectedItemToBuy || {}" :item="selectedItemToBuy || {}"
@@ -83,7 +89,7 @@
<router-view /> <router-view />
</div> </div>
</div> </div>
<app-footer /> <app-footer v-if="!hideFooter" />
<audio <audio
id="sound" id="sound"
ref="sound" ref="sound"
@@ -97,13 +103,20 @@
<style lang='scss' scoped> <style lang='scss' scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/variables.scss';
#app { #app {
height: calc(100% - 56px); /* 56px is the menu */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
&.resting {
--banner-resting-height: #{$restingToolbarHeight};
}
&.giftingBanner {
--banner-gifting-height: 2.5rem;
}
} }
#loading-screen-inapp { #loading-screen-inapp {
@@ -148,6 +161,13 @@
flex: 1 0 auto; flex: 1 0 auto;
} }
.no-margin {
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
.notification { .notification {
border-radius: 1000px; border-radius: 1000px;
background-color: $green-10; background-color: $green-10;
@@ -160,7 +180,7 @@
.resting-banner { .resting-banner {
width: 100%; width: 100%;
min-height: 40px; height: $restingToolbarHeight;
background-color: $blue-10; background-color: $blue-10;
top: 0; top: 0;
z-index: 1300; z-index: 1300;
@@ -302,7 +322,13 @@ export default {
return this.$t(`tip${tipNumber}`); return this.$t(`tip${tipNumber}`);
}, },
showRestingBanner () { showRestingBanner () {
return !this.bannerHidden && this.user.preferences.sleep; return !this.bannerHidden && this.user && this.user.preferences.sleep;
},
noMargin () {
return ['privateMessages'].includes(this.$route.name);
},
hideFooter () {
return ['privateMessages'].includes(this.$route.name);
}, },
}, },
created () { created () {

View File

@@ -1,21 +1,27 @@
.promo_armoire_backgrounds_202001 { .promo_armoire_backgrounds_202001 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -175px; background-position: 0px -323px;
width: 423px; width: 423px;
height: 147px; height: 147px;
} }
.promo_g1g1_2019 { .promo_g1g1_2019 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -471px; background-position: -403px -471px;
width: 357px; width: 357px;
height: 144px; height: 144px;
} }
.promo_mystery_202001 { .promo_mystery_202001 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -241px -616px; background-position: -241px -619px;
width: 279px; width: 279px;
height: 147px; height: 147px;
} }
.promo_seasonal_shop {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -521px -619px;
width: 162px;
height: 138px;
}
.promo_snowballs { .promo_snowballs {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px 0px; background-position: 0px 0px;
@@ -24,25 +30,25 @@
} }
.promo_take_this { .promo_take_this {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -358px -471px; background-position: -761px -471px;
width: 96px; width: 96px;
height: 69px; height: 69px;
} }
.promo_winter_potions_2020 { .promo_winter_potions_2020 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -175px; background-position: 0px -175px;
width: 423px; width: 423px;
height: 147px; height: 147px;
} }
.promo_winter_quests_bundle { .promo_winter_quests_bundle {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -323px; background-position: -424px -175px;
width: 423px; width: 423px;
height: 147px; height: 147px;
} }
.promo_winter_wonderland_2019 { .promo_winter_wonderland_2019 {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -323px; background-position: 0px -471px;
width: 402px; width: 402px;
height: 147px; height: 147px;
} }
@@ -52,9 +58,27 @@
width: 468px; width: 468px;
height: 147px; height: 147px;
} }
.promo_wintery_hair {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -684px -619px;
width: 152px;
height: 75px;
}
.promo_wintery_skins {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -424px -323px;
width: 420px;
height: 147px;
}
.customize-option.promo_wintery_skins {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -449px -338px;
width: 60px;
height: 60px;
}
.scene_list { .scene_list {
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png'); background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: 0px -616px; background-position: 0px -619px;
width: 240px; width: 240px;
height: 195px; height: 195px;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -7,3 +7,6 @@ $npc_quests_flavor: 'nye';
$npc_seasonal_flavor: 'nye'; $npc_seasonal_flavor: 'nye';
$npc_timetravelers_flavor: 'winter'; $npc_timetravelers_flavor: 'winter';
$npc_tavern_flavor: 'nye'; $npc_tavern_flavor: 'nye';
$restingToolbarHeight: 40px;
$menuToolbarHeight: 56px;

View File

@@ -0,0 +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="M15.7 14.3l-4.8-4.8c.7-1 1.1-2.2 1.1-3.5 0-3.3-2.7-6-6-6S0 2.7 0 6s2.7 6 6 6c1.3 0 2.5-.4 3.5-1.1l4.8 4.8c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4zM6 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="24" viewBox="0 0 32 24">
<g fill="none" fill-rule="evenodd">
<path fill="#9A62FF" d="M28 0H4C1.79 0 0 1.79 0 4v16c0 2.21 1.79 4 4 4h24c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z"/>
<path fill="#BDA8FF" d="M28 20H4V4l12 10L28 4z"/>
<path fill="#D5C8FF" d="M28 20H4V7l12 10L28 7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -32,7 +32,7 @@
:avatar-only="true" :avatar-only="true"
:with-background="true" :with-background="true"
/> />
<!-- @TOOD: Sleep +generatedAvatar({sleep:true})--> <!-- @TODO: Sleep +generatedAvatar({sleep:true})-->
<span class="knockout"></span> <span class="knockout"></span>
</div> </div>
</div> </div>

View File

@@ -71,8 +71,8 @@ export default {
this.$root.$emit('bv::hide::modal', 'streak'); this.$root.$emit('bv::hide::modal', 'streak');
}, },
suppressModals () { suppressModals () {
const surpress = !!this.user.preferences.suppressModals.streak; const suppress = !!this.user.preferences.suppressModals.streak;
this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': surpress }); this.$store.dispatch('user:set', { 'preferences.suppressModals.streak': suppress });
}, },
}, },
}; };

View File

@@ -1,5 +1,18 @@
<template> <template>
<div class="form-wrapper"> <div class="form-wrapper">
<div class="warning-banner d-flex" v-if="forgotPassword && preOutage">
<div class="warning-box ml-auto my-auto mr-2 d-flex">
<div
class="svg-icon exclamation m-auto"
v-html="icons.exclamation"
>
</div>
</div>
<div class="mr-auto my-auto">
Habitica emails will be temporarily unavailable on <strong>January 11, 2020</strong> from
<strong>1:00 - 7:00 AM EST</strong>.
</div>
</div>
<div id="top-background"> <div id="top-background">
<div class="seamless_stars_varied_opacity_repeat"></div> <div class="seamless_stars_varied_opacity_repeat"></div>
</div> </div>
@@ -546,15 +559,36 @@
font-size: 90%; font-size: 90%;
width: 100%; width: 100%;
} }
.warning-banner {
color: $white;
background-color: $maroon-100;
height: 2.5rem;
width: 100%;
}
.warning-box {
font-weight: bold;
width: 1rem;
height: 1rem;
border: 2px solid;
border-radius: 2px;
}
.exclamation {
width: 2px;
}
</style> </style>
<script> <script>
import axios from 'axios'; import axios from 'axios';
import hello from 'hellojs'; import hello from 'hellojs';
import moment from 'moment';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail'; import isEmail from 'validator/lib/isEmail';
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants'; import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import exclamation from '@/assets/svg/exclamation.svg';
import gryphon from '@/assets/svg/gryphon.svg'; import gryphon from '@/assets/svg/gryphon.svg';
import habiticaIcon from '@/assets/svg/habitica-logo.svg'; import habiticaIcon from '@/assets/svg/habitica-logo.svg';
import facebookSquareIcon from '@/assets/svg/facebook-square.svg'; import facebookSquareIcon from '@/assets/svg/facebook-square.svg';
@@ -576,6 +610,7 @@ export default {
}; };
data.icons = Object.freeze({ data.icons = Object.freeze({
exclamation,
gryphon, gryphon,
habiticaIcon, habiticaIcon,
facebookIcon: facebookSquareIcon, facebookIcon: facebookSquareIcon,
@@ -635,6 +670,9 @@ export default {
|| this.passwordInvalid || this.passwordInvalid
|| this.passwordConfirmInvalid; || this.passwordConfirmInvalid;
}, },
preOutage () {
return moment.utc().isBefore('2020-01-12');
},
}, },
watch: { watch: {
$route: { $route: {
@@ -700,7 +738,7 @@ export default {
return; return;
} }
// @TODO: implement langauge and invite accepting // @TODO: implement language and invite accepting
// var url = ApiUrl.get() + "/api/v4/user/auth/local/register"; // var url = ApiUrl.get() + "/api/v4/user/auth/local/register";
// if (location.search && location.search.indexOf('Invite=') !== -1) // if (location.search && location.search.indexOf('Invite=') !== -1)
// { // matches groupInvite and partyInvite // { // matches groupInvite and partyInvite

View File

@@ -294,8 +294,9 @@
justify-content: space-evenly; justify-content: space-evenly;
background-color: $gray-700; background-color: $gray-700;
text-align: center; text-align: center;
padding: 16px; padding: 8px;
border-radius: .25em; border-radius: .25em;
flex-wrap: wrap;
> div { > div {
.value { .value {
@@ -326,6 +327,8 @@
} }
> div.muted { > div.muted {
margin: 8px;
.value { .value {
opacity: 0.5; opacity: 0.5;
font-size: 20px; font-size: 20px;

View File

@@ -101,7 +101,7 @@
</div> </div>
</div> </div>
<div <div
v-if="showCategorySelect && creating" v-if="showCategorySelect"
class="category-box" class="category-box"
> >
<!-- eslint-disable vue/no-use-v-if-with-v-for --> <!-- eslint-disable vue/no-use-v-if-with-v-for -->

View File

@@ -68,6 +68,17 @@
<style lang='scss' scoped> <style lang='scss' scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@media only screen and (max-width: 768px) {
.header-row {
margin-bottom: 1rem;
}
.col-10.standard-page {
// full width on smaller devices
max-width: 100%;
}
}
.header-row { .header-row {
h1 { h1 {
color: $purple-200; color: $purple-200;

View File

@@ -88,6 +88,17 @@
<style lang='scss' scoped> <style lang='scss' scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@media only screen and (max-width: 768px) {
.header-row {
margin-bottom: 1rem;
}
.col-10.standard-page {
// full width on smaller devices
max-width: 100%;
}
}
.header-row { .header-row {
h1 { h1 {
color: $purple-200; color: $purple-200;

View File

@@ -5,7 +5,7 @@
class="mentioned-icon" class="mentioned-icon"
></div> ></div>
<div <div
v-if="!inbox && user.contributor.admin && msg.flagCount" v-if="user.contributor.admin && msg.flagCount"
class="message-hidden" class="message-hidden"
> >
{{ flagCountDescription }} {{ flagCountDescription }}
@@ -27,8 +27,7 @@
class="mr-1" class="mr-1"
></span> ></span>
<span <span
v-b-tooltip v-b-tooltip.hover="messageDate"
:title="msg.timestamp | date"
>{{ msg.timestamp | timeAgo }}&nbsp;</span> >{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span> <span v-if="msg.client && user.contributor.level >= 4">({{ msg.client }})</span>
</p> </p>
@@ -37,21 +36,12 @@
class="text" class="text"
v-html="atHighlight(parseMarkdown(msg.text))" v-html="atHighlight(parseMarkdown(msg.text))"
></div> ></div>
<div
v-if="isMessageReported && (inbox === true)"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span>
<br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr> <hr>
<div <div
v-if="msg.id" v-if="msg.id"
class="d-flex" class="d-flex"
> >
<div <div
v-if="!inbox"
class="action d-flex align-items-center" class="action d-flex align-items-center"
@click="copyAsTodo(msg)" @click="copyAsTodo(msg)"
> >
@@ -62,7 +52,7 @@
<div>{{ $t('copyAsTodo') }}</div> <div>{{ $t('copyAsTodo') }}</div>
</div> </div>
<div <div
v-if="(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')) v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
&& (!isMessageReported || user.contributor.admin)" && (!isMessageReported || user.contributor.admin)"
class="action d-flex align-items-center" class="action d-flex align-items-center"
@click="report(msg)" @click="report(msg)"
@@ -77,7 +67,7 @@
</div> </div>
</div> </div>
<div <div
v-if="msg.uuid === user._id || inbox || user.contributor.admin" v-if="msg.uuid === user._id || user.contributor.admin"
class="action d-flex align-items-center" class="action d-flex align-items-center"
@click="remove()" @click="remove()"
> >
@@ -91,7 +81,6 @@
</div> </div>
</div> </div>
<div <div
v-if="!inbox"
v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}" v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}"
class="ml-auto d-flex" class="ml-auto d-flex"
> >
@@ -121,7 +110,7 @@
></div> ></div>
</div> </div>
</div> </div>
<span v-if="!msg.likes[user._id] && !inbox">{{ $t('like') }}</span> <span v-if="!msg.likes[user._id]">{{ $t('like') }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -205,15 +194,9 @@
color: $purple-400; color: $purple-400;
} }
} }
.reported {
margin-top: 18px;
color: $red-50;
}
</style> </style>
<script> <script>
import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp'; import escapeRegExp from 'lodash/escapeRegExp';
@@ -244,10 +227,6 @@ export default {
}, },
props: { props: {
msg: {}, msg: {},
inbox: {
type: Boolean,
default: false,
},
groupId: {}, groupId: {},
}, },
data () { data () {
@@ -311,6 +290,10 @@ export default {
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden'; if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)'; return 'Message hidden (shadow-muted)';
}, },
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
}, },
mounted () { mounted () {
const links = this.$refs.markdownContainer.getElementsByTagName('a'); const links = this.$refs.markdownContainer.getElementsByTagName('a');
@@ -372,11 +355,6 @@ export default {
const message = this.msg; const message = this.msg;
this.$emit('message-removed', message); this.$emit('message-removed', message);
if (this.inbox) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
return;
}
await this.$store.dispatch('chat:deleteChat', { await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId, groupId: this.groupId,
chatId: message.id, chatId: message.id,

View File

@@ -35,13 +35,11 @@
v-for="msg in messages" v-for="msg in messages"
v-if="chat && canViewFlag(msg)" v-if="chat && canViewFlag(msg)"
:key="msg.id" :key="msg.id"
:class="{row: inbox}"
> >
<!-- eslint-enable vue/no-use-v-if-with-v-for --> <!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div <div
v-if="user._id !== msg.uuid" v-if="user._id !== msg.uuid"
class="d-flex" class="d-flex"
:class="{'flex-grow-1': inbox}"
> >
<avatar <avatar
v-if="msg.userStyles v-if="msg.userStyles
@@ -51,16 +49,13 @@
:avatar-only="true" :avatar-only="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
:hide-class-badge="true" :hide-class-badge="true"
:class="{'inbox-avatar-left': inbox}"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
<div <div
class="card" class="card"
:class="{'col-10': inbox}"
> >
<chat-card <chat-card
:msg="msg" :msg="msg"
:inbox="inbox"
:group-id="groupId" :group-id="groupId"
@message-liked="messageLiked" @message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@@ -72,15 +67,12 @@
<div <div
v-if="user._id === msg.uuid" v-if="user._id === msg.uuid"
class="d-flex" class="d-flex"
:class="{'flex-grow-1': inbox}"
> >
<div <div
class="card" class="card"
:class="{'col-10': inbox}"
> >
<chat-card <chat-card
:msg="msg" :msg="msg"
:inbox="inbox"
:group-id="groupId" :group-id="groupId"
@message-liked="messageLiked" @message-liked="messageLiked"
@message-removed="messageRemoved" @message-removed="messageRemoved"
@@ -95,7 +87,6 @@
:avatar-only="true" :avatar-only="true"
:hide-class-badge="true" :hide-class-badge="true"
:override-top-padding="'14px'" :override-top-padding="'14px'"
:class="{'inbox-avatar-right': inbox}"
@click.native="showMemberModal(msg.uuid)" @click.native="showMemberModal(msg.uuid)"
/> />
</div> </div>
@@ -144,16 +135,6 @@
margin-right: 2rem; margin-right: 2rem;
} }
.inbox-avatar-left {
margin-left: -1rem;
margin-right: 2.5rem;
min-width: 5rem;
}
.inbox-avatar-right {
margin-left: -3.5rem;
}
.hr { .hr {
width: 100%; width: 100%;
height: 20px; height: 20px;
@@ -209,10 +190,6 @@ export default {
}, },
props: { props: {
chat: {}, chat: {},
inbox: {
type: Boolean,
default: false,
},
groupType: {}, groupType: {},
groupId: {}, groupId: {},
groupName: {}, groupName: {},
@@ -260,12 +237,6 @@ export default {
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight); this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll // disable scroll
container.style.overflowY = 'hidden'; container.style.overflowY = 'hidden';
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
if (canLoadMore) {
await this.$emit('triggerLoad');
this.handleScrollBack = true;
}
}, },
canViewFlag (message) { canViewFlag (message) {
if (message.uuid === this.user._id) return true; if (message.uuid === this.user._id) return true;
@@ -282,7 +253,7 @@ export default {
const promises = []; const promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0; const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination // @TODO: write an explanation
// @TODO: Remove this after enough messages are cached // @TODO: Remove this after enough messages are cached
if ( if (
!noProfilesLoaded !noProfilesLoaded
@@ -380,11 +351,6 @@ export default {
this.chat.splice(chatIndex, 1, message); this.chat.splice(chatIndex, 1, message);
}, },
messageRemoved (message) { messageRemoved (message) {
if (this.inbox) {
this.$emit('message-removed', message);
return;
}
const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id); const chatIndex = findIndex(this.chat, chatMessage => chatMessage.id === message.id);
this.chat.splice(chatIndex, 1); this.chat.splice(chatIndex, 1);
}, },

View File

@@ -23,7 +23,7 @@
</div> </div>
<div class="footer text-center"> <div class="footer text-center">
<button <button
v-if="user.contributor.admin && abuseObject.flagCount > 0" v-if="user.contributor.admin"
class="pull-left btn btn-danger" class="pull-left btn btn-danger"
@click="clearFlagCount()" @click="clearFlagCount()"
> >

View File

@@ -382,7 +382,7 @@
<div class="task-option"> <div class="task-option">
<div class="custom-control custom-checkbox"> <div class="custom-control custom-checkbox">
<input <input
id="excercise" id="exercise"
v-model="taskCategories" v-model="taskCategories"
class="custom-control-input" class="custom-control-input"
type="checkbox" type="checkbox"
@@ -391,7 +391,7 @@
<label <label
v-once v-once
class="custom-control-label" class="custom-control-label"
for="excercise" for="exercise"
>{{ $t('exercise') }}</label> >{{ $t('exercise') }}</label>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,47 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import FaceAvatar from './faceAvatar.vue';
import Avatar from './avatar.vue';
import { userStyles } from '../../config/storybook/mock.data';
import content from '../../../common/script/content/index';
import getters from '@/store/getters';
storiesOf('Face Avatar', module)
.add('simple', () => ({
components: { FaceAvatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
}))
.add('compare', () => ({
components: { FaceAvatar, Avatar },
template: `
<div style="position: absolute; margin: 20px">
<face-avatar :member="user"></face-avatar>
<avatar :member="user"></avatar>
</div>
`,
data () {
return {
user: userStyles,
};
},
state: {
content,
},
store: {
getters,
state: {
content,
},
},
}));

View File

@@ -0,0 +1,154 @@
<template>
<div
class="face-avatar"
:style="{width, height}"
>
<div class="character-sprites">
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
<template v-for="(klass, item) in visualBuffs">
<span
v-if="member.stats.buffs[item] && showVisualBuffs"
:key="klass"
:class="klass"
></span>
</template>
<!-- Show flower ALL THE TIME!!!-->
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
<!-- Show avatar only if not currently affected by visual buff-->
<template v-if="showAvatar()">
<span :class="[skinClass]"></span><span :class="['head_0']"></span>
<template v-for="type in ['bangs', 'base', 'mustache', 'beard']">
<span
:key="type"
:class="[getHairClass(type)]"
></span>
</template>
<span :class="[getGearClass('body')]"></span>
<span :class="[getGearClass('eyewear')]"></span>
<span :class="[getGearClass('head')]"></span>
<span :class="[getGearClass('headAccessory')]"></span>
<span :class="['hair_flower_' + member.preferences.hair.flower]"></span>
</template>
<!-- Resting--><span
v-if="member.preferences.sleep"
class="zzz"
></span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.face-avatar {
width: 36px;
height: 36px;
border: solid 2px currentColor;
border-radius: 18px;
image-rendering: pixelated;
position: relative;
overflow: hidden;
}
.character-sprites {
width: 90px;
height: 90px;
margin: -25px -41px;
}
.character-sprites span {
position: absolute;
}
</style>
<script>
import { mapState } from '@/libs/store';
export default {
components: {
},
props: {
member: {
type: Object,
required: true,
},
avatarOnly: {
type: Boolean,
default: false,
},
hideClassBadge: {
type: Boolean,
default: false,
},
withBackground: {
type: Boolean,
},
overrideAvatarGear: {
type: Object,
},
width: {
type: Number,
default: 140,
},
height: {
type: Number,
default: 147,
},
showVisualBuffs: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
flatGear: 'content.gear.flat',
}),
hasClass () {
return this.$store.getters['members:hasClass'](this.member);
},
isBuffed () {
return this.$store.getters['members:isBuffed'](this.member);
},
visualBuffs () {
return {
snowball: 'snowman',
spookySparkles: 'ghost',
shinySeed: `avatar_floral_${this.member.stats.class}`,
seafoam: 'seafoam_star',
};
},
skinClass () {
const baseClass = `skin_${this.member.preferences.skin}`;
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
},
costumeClass () {
return this.member.preferences.costume ? 'costume' : 'equipped';
},
},
methods: {
getGearClass (gearType) {
let result = this.member.items.gear[this.costumeClass][gearType];
if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
result = this.overrideAvatarGear[gearType];
}
return result;
},
showAvatar () {
if (!this.showVisualBuffs) return true;
const { buffs } = this.member.stats;
return !buffs.snowball && !buffs.spookySparkles && !buffs.shinySeed && !buffs.seafoam;
},
getHairClass (type) {
const hairPref = this.member.preferences.hair;
return `hair_${type}_${hairPref[type]}_${hairPref.color}`;
},
},
};
</script>

View File

@@ -499,7 +499,7 @@ export default {
}, },
watch: { watch: {
groupId () { groupId () {
// @TOOD: We might not need this since groupId is computed now // @TODO: We might not need this since groupId is computed now
this.getMembers(); this.getMembers();
}, },
challengeId () { challengeId () {
@@ -536,13 +536,12 @@ export default {
}, },
methods: { methods: {
sendMessage (member) { sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', { this.$store.dispatch('user:newPrivateMessageTo', {
userIdToMessage: member._id, member,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
}); });
this.$root.$emit('bv::hide::modal', 'members-modal');
this.$router.push('/private-messages');
}, },
async searchMembers (searchTerm = '') { async searchMembers (searchTerm = '') {
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({ this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({

View File

@@ -8,7 +8,7 @@
<div <div
id="app-header" id="app-header"
class="row" class="row"
:class="{'hide-header': $route.name === 'groupPlan'}" :class="{'hide-header': hideHeader}"
> >
<members-modal :hide-badge="true" /> <members-modal :hide-badge="true" />
<member-details <member-details
@@ -171,6 +171,9 @@ export default {
sortedPartyMembers () { sortedPartyMembers () {
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]); return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
}, },
hideHeader () {
return ['groupPlan', 'privateMessages'].includes(this.$route.name);
},
}, },
created () { created () {
if (this.user.party && this.user.party._id) { if (this.user.party && this.user.party._id) {

View File

@@ -1,6 +1,5 @@
<template> <template>
<div> <div>
<inbox-modal />
<creator-intro /> <creator-intro />
<profileModal /> <profileModal />
<report-flag-modal /> <report-flag-modal />
@@ -408,6 +407,7 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/assets/scss/colors.scss'; @import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/utils.scss'; @import '~@/assets/scss/utils.scss';
@import '~@/assets/scss/variables.scss';
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
.chevron { .chevron {
@@ -438,7 +438,7 @@
} }
.topbar { .topbar {
max-height: 56px; max-height: $menuToolbarHeight;
.currency-tray { .currency-tray {
margin-left: auto; margin-left: auto;
@@ -721,7 +721,6 @@ import chevronDownIcon from '@/assets/svg/chevron-down.svg';
import logo from '@/assets/svg/logo.svg'; import logo from '@/assets/svg/logo.svg';
import creatorIntro from '../creatorIntro'; import creatorIntro from '../creatorIntro';
import InboxModal from '../userMenu/inbox.vue';
import notificationMenu from './notificationsDropdown'; import notificationMenu from './notificationsDropdown';
import profileModal from '../userMenu/profileModal'; import profileModal from '../userMenu/profileModal';
import reportFlagModal from '../chat/reportFlagModal'; import reportFlagModal from '../chat/reportFlagModal';
@@ -733,7 +732,6 @@ import userDropdown from './userDropdown';
export default { export default {
components: { components: {
creatorIntro, creatorIntro,
InboxModal,
notificationMenu, notificationMenu,
profileModal, profileModal,
reportFlagModal, reportFlagModal,

View File

@@ -3,7 +3,7 @@
:can-remove="canRemove" :can-remove="canRemove"
:has-icon="true" :has-icon="true"
:notification="notification" :notification="notification"
:read-after-click="true" :read-after-click="false"
@click="action" @click="action"
> >
<div <div

View File

@@ -25,7 +25,7 @@ export default {
props: ['notification', 'canRemove'], props: ['notification', 'canRemove'],
methods: { methods: {
action () { action () {
this.$root.$emit('bv::show::modal', 'inbox-modal'); this.$router.push('/private-messages');
}, },
}, },
}; };

View File

@@ -139,7 +139,7 @@ import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints'; import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems'; import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
import CARD_RECEIVED from './notifications/cardReceived'; import CARD_RECEIVED from './notifications/cardReceived';
import NEW_INBOX_MESSAGE from './notifications/newInboxMessage'; import NEW_INBOX_MESSAGE from './notifications/newPrivateMessage';
import NEW_CHAT_MESSAGE from './notifications/newChatMessage'; import NEW_CHAT_MESSAGE from './notifications/newChatMessage';
import WORLD_BOSS from './notifications/worldBoss'; import WORLD_BOSS from './notifications/worldBoss';
import VERIFY_USERNAME from './notifications/verifyUsername'; import VERIFY_USERNAME from './notifications/verifyUsername';

View File

@@ -33,7 +33,7 @@
<a <a
class="nav-link dropdown-item class="nav-link dropdown-item
dropdown-separated d-flex justify-content-between align-items-center" dropdown-separated d-flex justify-content-between align-items-center"
@click.prevent="showInbox()" @click.prevent="showPrivateMessages()"
> >
<div>{{ $t('messages') }}</div> <div>{{ $t('messages') }}</div>
<message-count <message-count
@@ -43,7 +43,7 @@
</a> </a>
<a <a
class="dropdown-item" class="dropdown-item"
@click="showAvatar('backgrounds', '2019')" @click="showAvatar('backgrounds', '2020')"
>{{ $t('backgrounds') }}</a> >{{ $t('backgrounds') }}</a>
<a <a
class="dropdown-item" class="dropdown-item"
@@ -163,10 +163,15 @@ export default {
this.$store.state.avatarEditorOptions.subpage = subpage; this.$store.state.avatarEditorOptions.subpage = subpage;
this.$root.$emit('bv::show::modal', 'avatar-modal'); this.$root.$emit('bv::show::modal', 'avatar-modal');
}, },
showInbox () { showPrivateMessages () {
markPMSRead(this.user); markPMSRead(this.user);
axios.post('/api/v4/user/mark-pms-read'); axios.post('/api/v4/user/mark-pms-read');
this.$root.$emit('bv::show::modal', 'inbox-modal');
if (this.$router.history.current.name === 'privateMessages') {
this.$root.$emit('pm::refresh');
} else {
this.$router.push('/private-messages');
}
}, },
showProfile (startingPage) { showProfile (startingPage) {
this.$router.push({ name: startingPage }); this.$router.push({ name: startingPage });

View File

@@ -341,7 +341,7 @@ export default {
const { firstRender } = viewOptions; const { firstRender } = viewOptions;
const { itemsInFirstPosition } = viewOptions; const { itemsInFirstPosition } = viewOptions;
// Render selected items in first postion only for the first render // Render selected items in first position only for the first render
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) { if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
gearItemsByType[type].unshift(ownedItem); gearItemsByType[type].unshift(ownedItem);
} else if (isEquipped === true && firstRender === true) { } else if (isEquipped === true && firstRender === true) {
@@ -382,7 +382,7 @@ export default {
const { firstRender } = viewOptions; const { firstRender } = viewOptions;
const { itemsInFirstPosition } = viewOptions; const { itemsInFirstPosition } = viewOptions;
// Render selected items in first postion only for the first render // Render selected items in first position only for the first render
if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) { if (itemsInFirstPosition.indexOf(ownedItem.key) !== -1 && firstRender === false) {
gearItemsByClass[klass].unshift(ownedItem); gearItemsByClass[klass].unshift(ownedItem);
} else if (isEquipped === true && firstRender === true) { } else if (isEquipped === true && firstRender === true) {

View File

@@ -0,0 +1,235 @@
<template>
<div
class="conversation"
:class="{active: activeKey === uuid}"
@click="$emit('click', {})"
@mouseleave="hideDropDown()"
>
<div class="user">
<user-label
:backer="backer"
:contributor="contributor"
:name="displayName"
/><span
v-if="username"
class="username"
>@{{ username }}</span>
<div
v-if="lastMessageDate"
class="time"
>
{{ lastMessageDate | timeAgo }}
</div>
</div>
<div class="preview-row">
<div class="messagePreview">
{{ lastMessageText }}
</div>
<div
v-if="userLoggedIn.id !== uuid"
class="actions"
>
<b-dropdown
ref="dropdown"
class="action-dropdown"
right
toggle-class="btn-flat action-padding"
no-caret
variant="link"
size="lg"
>
<template slot="button-content">
<div
class="svg-icon inline dots"
v-html="icons.dots"
></div>
</template>
<b-dropdown-item @click="block()">
<span class="dropdown-icon-item">
<div
class="svg-icon inline"
v-html="icons.remove"
></div><span class="text">{{ $t('block') }}</span></span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</template>
<script>
import moment from 'moment';
import userLabel from '../userLabel';
import dots from '@/assets/svg/dots.svg';
import remove from '@/assets/svg/remove.svg';
import { mapState } from '@/libs/store';
export default {
components: {
userLabel,
},
props: [
'activeKey', 'uuid', 'backer', 'displayName',
'username', 'contributor', 'lastMessageText',
'lastMessageDate',
],
computed: {
...mapState({
userLoggedIn: 'user.data',
}),
},
data () {
return {
icons: Object.freeze({
dots,
remove,
}),
};
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
methods: {
hideDropDown () {
const { dropdown } = this.$refs;
if (dropdown) {
dropdown.hide();
}
},
block () {
this.$store.dispatch('user:block', {
uuid: this.uuid,
});
},
},
};
</script>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
.action-padding {
height: 24px !important;
width: 24px;
padding: 0 !important;
}
.action-dropdown {
.dropdown-item {
padding: 12px 16px;
.svg-icon {
width: 16px;
height: 16px;
}
}
.dots {
height: 16px;
width: 4px;
svg path {
fill: $purple-300
}
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.conversation {
padding: 1.5rem;
border-bottom: 1px solid $gray-500;
&:hover {
cursor: pointer;
background: #EEE;
.actions {
display: block;
}
}
&.active {
background-color: #f1edff;
}
.user {
display: flex;
flex-direction: row;
height: 20px;
.user-label {
flex: 1;
flex-grow: 0;
margin-right: 0.5rem;
white-space: nowrap;
}
.username {
flex: 1;
flex-grow: 0;
}
.time {
flex: 2;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 1rem;
}
}
.messagePreview {
//width: 100%;
height: 30px;
margin-right: 40px;
margin-top: 4px;
font-size: 12px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
color: $gray-100;
overflow: hidden;
// text-overflow: ellipsis;
}
}
.preview-row {
display: flex;
flex-direction: row;
position: relative;
.messagePreview {
flex: 1;
width: calc(100% - 16px);
}
}
.actions {
position: absolute;
right: 0;
display: none;
width: 16px;
margin-top: 4px;
.dots {
height: 16px;
width: 4px;
}
.action-icon {
margin-right: 1em;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="card-body">
<user-link
:user-id="msg.uuid"
:name="msg.user"
:backer="msg.backer"
:contributor="msg.contributor"
/>
<p class="time">
<span
v-if="msg.username"
class="mr-1"
>@{{ msg.username }}</span><span
v-if="msg.username"
class="mr-1"
></span>
<span
v-b-tooltip.hover="messageDate"
>{{ msg.timestamp | timeAgo }}&nbsp;</span>
<span v-if="msg.client && user.contributor.level >= 4"> ({{ msg.client }})</span>
</p>
<div
class="text"
v-html="atHighlight(parseMarkdown(msg.text))"
></div>
<div
v-if="isMessageReported"
class="reported"
>
<span v-once>{{ $t('reportedMessage') }}</span><br>
<span v-once>{{ $t('canDeleteNow') }}</span>
</div>
<hr>
<div
v-if="msg.id"
class="d-flex"
>
<div
v-if="!isMessageReported"
class="action d-flex align-items-center"
@click="report(msg)"
>
<div
v-once
class="svg-icon"
v-html="icons.report"
></div>
<div v-once>
{{ $t('report') }}
</div>
</div>
<div
class="action d-flex align-items-center"
@click="remove()"
>
<div
v-once
class="svg-icon"
v-html="icons.delete"
></div>
<div v-once>
{{ $t('delete') }}
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem;
}
.at-text {
color: #6133b4;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
.action {
display: inline-block;
color: $gray-200;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: $gray-300;
margin-right: .2em;
width: 16px;
}
}
.active {
color: $purple-300;
.svg-icon {
color: $purple-400;
}
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: $gray-50;
text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
}
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
import userLink from '../userLink';
import deleteIcon from '@/assets/svg/delete.svg';
import reportIcon from '@/assets/svg/report.svg';
import { highlightUsers } from '../../libs/highlightUsers';
export default {
components: {
userLink,
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
props: {
msg: {},
},
data () {
return {
icons: Object.freeze({
delete: deleteIcon,
report: reportIcon,
}),
reported: false,
};
},
computed: {
...mapState({ user: 'user.data' }),
isMessageReported () {
return (this.msg.flags && this.msg.flags[this.user.id]) || this.reported;
},
messageDate () {
const date = moment(this.msg.timestamp).toDate();
return date.toString();
},
},
mounted () {
this.$emit('message-card-mounted');
},
methods: {
report () {
this.$root.$on('habitica:report-result', data => {
if (data.ok) {
this.reported = true;
}
this.$root.$off('habitica:report-result');
});
this.$root.$emit('habitica::report-chat', {
message: this.msg,
groupId: 'privateMessage',
});
},
async remove () {
if (!window.confirm(this.$t('areYouSureDeleteMessage'))) return;
const message = this.msg;
this.$emit('message-removed', message);
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
},
atHighlight (text) {
return highlightUsers(text, this.user.auth.local.username, this.user.profile.name);
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
},
};
</script>

View File

@@ -0,0 +1,400 @@
<template>
<perfect-scrollbar
ref="container"
class="container-fluid"
:class="{'disable-perfect-scroll': disablePerfectScroll}"
:options="psOptions"
>
<div class="row loadmore">
<div v-if="canLoadMore && !isLoading">
<div class="loadmore-divider"></div>
<button
class="btn btn-secondary"
@click="triggerLoad()"
>
{{ $t('loadEarlierMessages') }}
</button>
<div class="loadmore-divider"></div>
</div>
<h2
v-show="isLoading"
class="col-12 loading"
>
{{ $t('loading') }}
</h2>
</div>
<div
v-for="(msg) in messages"
:key="msg.id"
class="row message-row"
:class="{ 'margin-right': user._id !== msg.uuid}"
>
<div
v-if="user._id !== msg.uuid"
class="d-flex flex-grow-1"
>
<avatar
v-if="msg.userStyles || (cachedProfileData[msg.uuid]
&& !cachedProfileData[msg.uuid].rejected)"
class="avatar-left"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:override-top-padding="'14px'"
:hide-class-badge="true"
@click.native="showMemberModal(msg.uuid)"
/>
<div class="card card-right">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
</div>
<div
v-if="user._id === msg.uuid"
class="d-flex flex-grow-1"
>
<div class="card card-left">
<message-card
:msg="msg"
@message-removed="messageRemoved"
@show-member-modal="showMemberModal"
@message-card-mounted="itemWasMounted"
/>
</div>
<avatar
v-if="msg.userStyles
|| (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)"
class="avatar-right"
:member="msg.userStyles || cachedProfileData[msg.uuid]"
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
@click.native="showMemberModal(msg.uuid)"
/>
</div>
</div>
</perfect-scrollbar>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css';
.disable-perfect-scroll {
overflow-y: inherit !important;
}
.avatar {
width: 15%;
min-width: 8rem;
height: 120px;
padding-top: 0 !important;
}
.avatar-left {
margin-left: -1rem;
}
.avatar-right {
margin-left: -1rem;
}
.card {
border: 0px;
margin-bottom: 1rem;
padding: 0rem;
width: 684px;
}
.message-row {
margin-left: 12px;
margin-right: 12px;
&:not(.margin-right) {
.d-flex {
justify-content: flex-end;
}
}
}
@media only screen and (max-width: 1200px) {
.card {
width: 100%;
}
}
@media only screen and (min-width: 1400px) {
.message-row {
margin-left: -15px;
margin-right: -30px;
}
}
.card-left {
border: 1px solid $purple-500;
}
.card-right {
border: 1px solid $gray-500;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.hr-middle {
font-size: 16px;
font-weight: bold;
font-family: 'Roboto Condensed';
line-height: 1.5;
text-align: center;
color: $gray-200;
background-color: $gray-700;
padding: .2em;
margin-top: .2em;
display: inline-block;
width: 100px;
}
.loadmore {
justify-content: center;
margin-right: 12px;
> div {
display: flex;
width: 100%;
align-items: center;
button {
text-align: center;
color: $gray-50;
margin-top: 12px;
margin-bottom: 24px;
}
}
}
.loadmore-divider {
height: 1px;
background-color: $gray-500;
flex: 1;
margin-left: 24px;
margin-right: 24px;
&:last-of-type {
margin-right: 0;
}
}
.loading {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
</style>
<script>
import moment from 'moment';
import axios from 'axios';
import debounce from 'lodash/debounce';
import { PerfectScrollbar } from 'vue2-perfect-scrollbar';
import { mapState } from '@/libs/store';
import Avatar from '../avatar';
import messageCard from './messageCard';
export default {
components: {
Avatar,
messageCard,
PerfectScrollbar,
},
props: {
chat: {},
isLoading: Boolean,
canLoadMore: Boolean,
},
data () {
return {
currentDayDividerDisplay: moment().day(),
cachedProfileData: {},
currentProfileLoadedCount: 0,
currentProfileLoadedEnd: 10,
loading: false,
handleScrollBack: false,
lastOffset: -1,
disablePerfectScroll: false,
};
},
mounted () {
this.loadProfileCache();
this.$el.addEventListener('selectstart', () => this.handleSelectStart());
this.$el.addEventListener('mouseup', () => this.handleSelectChange());
},
created () {
window.addEventListener('scroll', this.handleScroll);
},
beforeDestroy () {
this.$el.removeEventListener('selectstart', () => this.handleSelectStart());
this.$el.removeEventListener('mouseup', () => this.handleSelectChange());
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll);
},
computed: {
...mapState({ user: 'user.data' }),
// @TODO: We need a different lazy load mechnism.
// But honestly, adding a paging route to chat would solve this
messages () {
this.loadProfileCache();
return this.chat;
},
psOptions () {
return {
suppressScrollX: true,
};
},
},
methods: {
handleScroll () {
this.loadProfileCache(window.scrollY / 1000);
},
async triggerLoad () {
const container = this.$refs.container.$el;
// get current offset
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll
// container.style.overflowY = 'hidden';
const canLoadMore = !this.isLoading && this.canLoadMore;
if (canLoadMore) {
const triggerLoadResult = this.$emit('triggerLoad');
await triggerLoadResult;
this.handleScrollBack = true;
}
},
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
this._loadProfileCache(screenPosition);
}, 1000),
async _loadProfileCache (screenPosition) {
if (this.loading) return;
this.loading = true;
const promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination
// @TODO: Remove this after enough messages are cached
if (!noProfilesLoaded && screenPosition
&& Math.floor(screenPosition) + 1 > this.currentProfileLoadedEnd / 10) {
this.currentProfileLoadedEnd = 10 * (Math.floor(screenPosition) + 1);
} else if (!noProfilesLoaded && screenPosition) {
return;
}
const aboutToCache = {};
this.messages.forEach(message => {
const { uuid } = message;
if (message.userStyles) {
this.$set(this.cachedProfileData, uuid, message.userStyles);
}
if (Boolean(uuid) && !this.cachedProfileData[uuid] && !aboutToCache[uuid]) {
if (uuid === 'system' || this.currentProfileLoadedCount === this.currentProfileLoadedEnd) return;
aboutToCache[uuid] = {};
promises.push(axios.get(`/api/v4/members/${uuid}`));
this.currentProfileLoadedCount += 1;
}
});
const results = await Promise.all(promises);
results.forEach(result => {
// We could not load the user. Maybe they were deleted.
// So, let's cache empty so we don't try again
if (!result || !result.data || result.status >= 400) {
return;
}
const userData = result.data.data;
this.$set(this.cachedProfileData, userData._id, userData);
});
// Merge in any attempts that were rejected so we don't attempt again
for (const uuid in aboutToCache) {
if (!this.cachedProfileData[uuid]) {
this.$set(this.cachedProfileData, uuid, { rejected: true });
}
}
this.loading = false;
},
displayDivider (message) {
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
this.currentDayDividerDisplay = moment(message.timestamp).day();
return true;
}
return false;
},
async showMemberModal (memberId) {
let profile = this.cachedProfileData[memberId];
if (!profile._id) {
const result = await this.$store.dispatch('members:fetchMember', { memberId });
if (result.response && result.response.status === 404) {
return this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('messageDeletedUser'),
type: 'error',
timeout: false,
});
}
this.cachedProfileData[memberId] = result.data.data;
profile = result.data.data;
}
// Open the modal only if the data is available
if (profile && !profile.rejected) {
this.$router.push({ name: 'userProfile', params: { userId: profile._id } });
}
return null;
},
itemWasMounted: debounce(function itemWasMounted () {
if (this.handleScrollBack) {
this.handleScrollBack = false;
const container = this.$refs.container.$el;
const offset = container.scrollHeight - container.clientHeight;
const newOffset = offset + this.lastOffset;
container.scrollTo(0, newOffset);
// enable scroll again
// container.style.overflowY = 'scroll';
}
}, 50),
messageRemoved (message) {
this.$emit('message-removed', message);
},
handleSelectStart () {
this.disablePerfectScroll = true;
},
handleSelectChange () {
this.disablePerfectScroll = false;
},
},
};
</script>

View File

@@ -28,7 +28,7 @@
<small>{{ $t('unsubscribeAllEmailsText') }}</small> <small>{{ $t('unsubscribeAllEmailsText') }}</small>
</div> </div>
<div class="col-8"> <div class="col-8">
<table class="table"></table> <table class="table">
<tr> <tr>
<td></td> <td></td>
<th> <th>
@@ -38,10 +38,7 @@
<span>{{ $t('push') }}</span> <span>{{ $t('push') }}</span>
</th> </th>
</tr> </tr>
<tr <tr v-for="notification in notificationsIds" :key="notification">
v-for="notification in notificationsIds"
:key="notification"
>
<td> <td>
<span>{{ $t(notification) }}</span> <span>{{ $t(notification) }}</span>
</td> </td>
@@ -58,9 +55,11 @@
type="checkbox" type="checkbox"
@change="set('pushNotifications', notification)" @change="set('pushNotifications', notification)"
> >
<hr> <td v-else>
&nbsp;
</td> </td>
</tr> </tr>
</table>
</div> </div>
</div> </div>
</template> </template>
@@ -74,6 +73,8 @@ export default {
data () { data () {
return { return {
notificationsIds: Object.freeze([ notificationsIds: Object.freeze([
'majorUpdates',
'onboarding',
'newPM', 'newPM',
'wonChallenge', 'wonChallenge',
'giftedGems', 'giftedGems',
@@ -85,8 +86,6 @@ export default {
'invitedQuest', 'invitedQuest',
'importantAnnouncements', 'importantAnnouncements',
'weeklyRecaps', 'weeklyRecaps',
'onboarding',
'majorUpdates',
'subscriptionReminders', 'subscriptionReminders',
]), ]),
// list of email-only notifications // list of email-only notifications

View File

@@ -812,7 +812,8 @@ export default {
this.localAuth.username = this.user.auth.local.username; this.localAuth.username = this.user.auth.local.username;
this.user.flags.verifiedUsername = true; this.user.flags.verifiedUsername = true;
} else if (attribute === 'email') { } else if (attribute === 'email') {
this.user.auth.local.email = updates[attribute]; this.user.auth.local.email = updates.newEmail;
window.alert(this.$t('emailSuccess'));
} }
}, },
async changeDisplayName (newName) { async changeDisplayName (newName) {

View File

@@ -1,8 +1,8 @@
<template> <template>
<b-modal <b-modal
id="task-modal" id="task-modal"
:no-close-on-esc="showTagsSelect" :no-close-on-esc="true"
:no-close-on-backdrop="showTagsSelect" :no-close-on-backdrop="true"
size="sm" size="sm"
@hidden="onClose()" @hidden="onClose()"
@show="handleOpen()" @show="handleOpen()"

View File

@@ -1,9 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue'; import { storiesOf } from '@storybook/vue';
import { withKnobs, number } from '@storybook/addon-knobs';
import CountBadge from './countBadge.vue'; import CountBadge from './countBadge.vue';
storiesOf('Count Badge', module) const stories = storiesOf('Count Badge', module);
stories.addDecorator(withKnobs);
stories
.add('simple', () => ({ .add('simple', () => ({
components: { CountBadge }, components: { CountBadge },
template: ` template: `
@@ -19,9 +24,9 @@ storiesOf('Count Badge', module)
<count-badge :count="count" :show="true"></count-badge> <count-badge :count="count" :show="true"></count-badge>
</div> </div>
`, `,
data () { props: {
return { count: {
count: 3, default: number('Count', 3),
}; },
}, },
})); }));

View File

@@ -2,14 +2,14 @@
<div class="popover-box"> <div class="popover-box">
<div <div
:id="containerId" :id="containerId"
class="clearfix" class="clearfix toggle-switch-outer"
> >
<div <div
v-if="label" v-if="label"
class="float-left toggle-switch-description" class="float-left toggle-switch-description"
:class="hoverText ? 'hasPopOver' : ''" :class="hoverText ? 'hasPopOver' : ''"
> >
{{ label }} <span>{{ label }}</span>
</div> </div>
<div class="toggle-switch float-left"> <div class="toggle-switch float-left">
<input <input
@@ -53,9 +53,7 @@
} }
.toggle-switch-description { .toggle-switch-description {
height: 20px; &.hasPopOver span {
&.hasPopOver {
border-bottom: 1px dashed $gray-200; border-bottom: 1px dashed $gray-200;
} }
} }

View File

@@ -0,0 +1,115 @@
<template>
<div
v-if="displayName"
v-b-tooltip.hover.top="tierTitle"
class="user-label"
:class="levelStyle()"
>
{{ displayName }}
<div
class="svg-icon"
v-html="tierIcon()"
></div>
</div>
</template>
<style scoped lang="scss">
@import '~@/assets/scss/colors.scss';
.user-label.no-tier {
color: $gray-50;
}
.user-label {
font-weight: bold;
margin-bottom: 0;
display: inline-block;
font-size: 16px;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
</style>
<script>
import achievementsLib from '@/../../common/script/libs/achievements';
import styleHelper from '@/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 {
mixins: [styleHelper],
props: ['user', 'name', 'backer', 'contributor', 'hideTooltip'],
data () {
return {
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
};
},
computed: {
displayName () {
if (this.name) {
return this.name;
}
if (this.user && this.user.profile) {
return this.user.profile.name;
}
return null;
},
level () {
if (this.contributor) {
return this.contributor.level;
} if (this.user && this.user.contributor) {
return this.user.contributor.level;
}
return 0;
},
isNPC () {
if (this.backer) {
return this.backer.level;
} if (this.user && this.user.backer) {
return this.user.backer.level;
}
return false;
},
},
methods: {
tierIcon () {
if (this.isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${this.level}`];
},
tierTitle () {
return this.hideTooltip ? '' : achievementsLib.getContribText(this.contributor, this.isNPC) || '';
},
levelStyle () {
return this.userLevelStyleFromLevel(this.level, this.isNPC);
},
},
};
</script>

View File

@@ -1,698 +0,0 @@
<template>
<b-modal
id="inbox-modal"
title
:hide-footer="true"
size="lg"
@shown="onModalShown"
@hide="onModalHide"
>
<div
slot="modal-header"
class="header-wrap container align-items-center"
>
<div class="row align-items-center">
<div class="col-4">
<div class="row align-items-center">
<div class="col-2">
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
</div>
<div class="col-6">
<h2
v-once
class="text-center"
>
{{ $t('messages') }}
</h2>
</div>
</div>
</div>
<div class="col-4 offset-3">
<toggle-switch
class="float-right"
:label="optTextSet.switchDescription"
:checked="!user.inbox.optOut"
:hover-text="optTextSet.popoverText"
@change="toggleOpt()"
/>
</div>
<div class="col-1">
<div class="close">
<span
class="svg-icon inline icon-10"
aria-hidden="true"
@click="close()"
v-html="icons.svgClose"
></span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-4 sidebar">
<div class="search-section">
<b-form-input
v-model="search"
:placeholder="$t('search')"
/>
</div>
<div
v-if="filtersConversations.length === 0"
class="empty-messages text-center"
>
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4 v-once>
{{ $t('emptyMessagesLine1') }}
</h4>
<p v-if="!user.flags.chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
<div
v-if="filtersConversations.length > 0"
class="conversations"
>
<div
v-for="conversation in filtersConversations"
:key="conversation.key"
class="conversation"
:class="{active: selectedConversation.key === conversation.key}"
@click="selectConversation(conversation.key)"
>
<div>
<h3 :class="userLevelStyle(conversation)">
{{ conversation.name }}
<div
class="svg-icon"
v-html="tierIcon(conversation)"
></div>
</h3>
</div>
<div class="time">
<span
v-if="conversation.username"
class="mr-1"
>@{{ conversation.username }} </span>
<span v-if="conversation.date">{{ conversation.date | timeAgo }}</span>
</div>
<div
class="messagePreview"
>
{{ conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : '' }}
</div>
</div>
</div>
</div>
<div class="col-8 messages d-flex flex-column justify-content-between">
<div
v-if="!selectedConversation.key"
class="empty-messages text-center"
>
<div
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4>{{ placeholderTexts.title }}</h4>
<p v-html="placeholderTexts.description"></p>
</div>
<div
v-if="selectedConversation && selectedConversationMessages.length === 0"
class="empty-messages text-center"
>
<p>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</p>
</div>
<chat-messages
v-if="selectedConversation && selectedConversationMessages.length > 0"
ref="chatscroll"
class="message-scroll"
:chat="selectedConversationMessages"
:inbox="true"
:can-load-more="canLoadMore"
:is-loading="messagesLoading"
@message-removed="messageRemoved"
@triggerLoad="infiniteScrollTrigger"
/>
<div
v-if="user.inbox.optOut && selectedConversation.key"
class="pm-disabled-caption text-center"
>
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
<p>{{ $t('PMDisabledCaptionText') }}</p>
</div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="new-message-row"
>
<textarea
v-model="newMessage"
maxlength="3000"
@keyup.ctrl.enter="sendPrivateMessage()"
></textarea>
<button
class="btn btn-secondary"
@click="sendPrivateMessage()"
>
{{ $t('send') }}
</button>
<div class="row">
<span class="ml-3">{{ currentLength }} / 3000</span>
</div>
</div>
</div>
</div>
</b-modal>
</template>
<style lang="scss">
#inbox-modal .modal-body {
padding-top: 0px;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.header-wrap {
padding: 0.5em;
h2 {
margin: 0;
line-height: 1;
}
}
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.envelope {
color: $gray-400 !important;
margin: 0;
}
.sidebar {
background-color: $gray-700;
min-height: 540px;
padding: 0;
.search-section {
padding: 1em;
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
}
}
.messages {
position: relative;
padding-left: 0;
padding-bottom: 6em;
height: 540px;
}
.message-scroll {
max-height: 500px;
overflow-x: scroll;
@media (min-width: 992px) {
overflow-x: hidden;
overflow-y: scroll;
}
}
.to-form input {
width: 60%;
display: inline-block;
margin-left: 1em;
}
.empty-messages {
margin-top: 10em;
color: $gray-400;
padding: 1em;
h4 {
color: $gray-400;
margin-top: 1em;
}
.envelope {
width: 30px;
margin: 0 auto;
}
}
.pm-disabled-caption {
padding-top: 1em;
background-color: $gray-700;
z-index: 2;
h4, p {
color: $gray-300;
}
h4 {
margin-top: 0;
margin-bottom: 0.4em;
}
p {
font-size: 12px;
margin-bottom: 0;
}
}
.new-message-row {
background-color: $gray-700;
position: absolute;
bottom: 0;
height: 88px;
width: 100%;
padding: 1em;
textarea {
height: 80%;
display: inline-block;
vertical-align: bottom;
width: 80%;
}
button {
vertical-align: bottom;
display: inline-block;
box-shadow: none;
margin-left: 1em;
}
}
.conversations {
max-height: 400px;
overflow-x: hidden;
overflow-y: scroll;
}
.conversation {
padding: 1.5em;
background: $white;
}
.conversation.active {
border: 1px solid $purple-400;
}
.conversation:hover {
cursor: pointer;
}
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.messagePreview {
display: block;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
</style>
<script>
import Vue from 'vue';
import moment from 'moment';
import filter from 'lodash/filter';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import habiticaMarkdown from 'habitica-markdown';
import axios from 'axios';
import { mapState } from '@/libs/store';
import styleHelper from '@/mixins/styleHelper';
import toggleSwitch from '@/components/ui/toggleSwitch';
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 {
components: {
chatMessages,
toggleSwitch,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper],
data () {
return {
icons: Object.freeze({
messageIcon,
svgClose,
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
displayCreate: true,
selectedConversation: {},
search: '',
newMessage: '',
showPopover: false,
messages: [],
messagesByConversation: {}, // cache {uuid: []}
loadedConversations: [],
loaded: false,
messagesLoading: false,
initiatedConversation: null,
updateConversionsCounter: 0,
};
},
computed: {
...mapState({ user: 'user.data' }),
canLoadMore () {
return this.selectedConversation && this.selectedConversation.canLoadMore;
},
conversations () {
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
// 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,
contributor: this.initiatedConversation.contributor,
id: '',
text: '',
timestamp: new Date(),
}];
}
// Create conversation objects
const convos = [];
for (const key of Object.keys(inboxGroup)) {
const recentMessage = inboxGroup[key][0];
const convoModel = {
key: recentMessage.uuid,
// Handles case where from user sent
// the only message or the to user sent the only message
name: recentMessage.user,
username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
canLoadMore: true,
page: 0,
};
convos.push(convoModel);
}
return convos;
},
// Separate from selectedConversation which
// is not computed so messages don't update automatically
selectedConversationMessages () {
// Vue-subscribe to changes
const subScribeToUpdate = this.messagesLoading || this.updateConversionsCounter > -1;
const selectedConversationKey = this.selectedConversation.key;
const selectedConversation = this.messagesByConversation[selectedConversationKey];
this.messages = selectedConversation || []; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
if (subScribeToUpdate) {
return ordered;
}
return [];
},
filtersConversations () {
// Vue-subscribe to changes
const subScribeToUpdate = this.updateConversionsCounter > -1;
const filtered = subScribeToUpdate && !this.search
? this.conversations
: filter(
this.conversations,
conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1,
);
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
return ordered;
},
currentLength () {
return this.newMessage.length;
},
placeholderTexts () {
if (this.user.flags.chatRevoked) {
return {
title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('chatPrivilegesRevoked'),
};
}
return {
title: this.$t('PMPlaceholderTitle'),
description: this.$t('PMPlaceholderDescription'),
};
},
optTextSet () {
if (!this.user.inbox.optOut) {
return {
switchDescription: this.$t('PMReceive'),
popoverText: this.$t('PMEnabledOptPopoverText'),
};
}
return {
switchDescription: this.$t('PMReceive'),
popoverText: this.$t('PMDisabledOptPopoverText'),
};
},
},
mounted () {
this.$root.$on('habitica::new-inbox-message', data => {
this.$root.$emit('bv::show::modal', 'inbox-modal');
// Wait for messages to be loaded
const unwatchLoaded = this.$watch('loaded', loaded => {
if (!loaded) return;
const conversation = this.conversations.find(convo => convo.key === data.userIdToMessage);
if (loaded) setImmediate(() => unwatchLoaded());
if (conversation) {
this.selectConversation(data.userIdToMessage);
return;
}
this.initiatedConversation = {
uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
};
this.selectConversation(data.userIdToMessage);
}, { immediate: true });
});
},
destroyed () {
this.$root.$off('habitica::new-inbox-message');
},
methods: {
async onModalShown () {
this.loaded = false;
const conversationRes = await axios.get('/api/v4/inbox/conversations');
this.loadedConversations = conversationRes.data.data;
this.loaded = true;
},
onModalHide () {
// reset everything
this.loadedConversations = [];
this.loaded = false;
this.initiatedConversation = null;
this.messagesByConversation = {};
this.selectedConversation = {};
},
messageRemoved (message) {
const messages = this.messagesByConversation[this.selectedConversation.key];
const messageIndex = messages.findIndex(msg => msg.id === message.id);
if (messageIndex !== -1) messages.splice(messageIndex, 1);
if (this.selectedConversationMessages.length === 0) {
this.initiatedConversation = {
uuid: this.selectedConversation.key,
user: this.selectedConversation.name,
username: this.selectedConversation.username,
backer: this.selectedConversation.backer,
contributor: this.selectedConversation.contributor,
};
}
},
toggleClick () {
this.displayCreate = !this.displayCreate;
},
toggleOpt () {
this.$store.dispatch('user:togglePrivateMessagesOpt');
},
async selectConversation (key) {
const convoFound = this.conversations.find(conversation => conversation.key === key);
this.selectedConversation = convoFound || {};
if (!this.messagesByConversation[this.selectedConversation.key]) {
await this.loadMessages();
}
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
},
sendPrivateMessage () {
if (!this.newMessage) return;
const messages = this.messagesByConversation[this.selectedConversation.key];
messages.push({
sent: true,
text: this.newMessage,
timestamp: new Date(),
toUser: this.selectedConversation.name,
toUserName: this.selectedConversation.username,
toUserContributor: this.selectedConversation.contributor,
toUserBacker: this.selectedConversation.backer,
toUUID: this.selectedConversation.uuid,
id: '-1', // will be updated once the result is back
likes: {},
ownerId: this.user._id,
uuid: this.user._id,
fromUUID: this.user._id,
user: this.user.profile.name,
username: this.user.auth.local.username,
contributor: this.user.contributor,
backer: this.user.backer,
});
// Remove the placeholder message
if (
this.initiatedConversation
&& this.initiatedConversation.uuid === this.selectedConversation.key
) {
this.loadedConversations.unshift(this.initiatedConversation);
this.initiatedConversation = null;
}
this.selectedConversation.lastMessageText = this.newMessage;
this.selectedConversation.date = new Date();
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
this.$store.dispatch('members:sendPrivateMessage', {
toUserId: this.selectedConversation.key,
message: this.newMessage,
}).then(response => {
const newMessage = response.data.data.message;
const messageToReset = messages[messages.length - 1];
messageToReset.id = newMessage.id; // just set the id, all other infos already set
Object.assign(messages[messages.length - 1], messageToReset);
this.updateConversionsCounter += 1;
});
this.newMessage = '';
},
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 null;
return this.icons[`tier${message.contributor.level}`];
},
removeTags (html) {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading)
if (this.canLoadMore) {
this.messagesLoading = true;
}
return this.loadMore();
},
loadMore () {
this.selectedConversation.page += 1;
return this.loadMessages();
},
async loadMessages () {
this.messagesLoading = true;
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${this.selectedConversation.key}&page=${this.selectedConversation.page}`;
const res = await axios.get(requestUrl);
const loadedMessages = res.data.data;
this.messagesByConversation[this.selectedConversation.key] = this.messagesByConversation[this.selectedConversation.key] || []; // eslint-disable-line max-len
const loadedMessagesToAdd = loadedMessages
.filter(m => this.messagesByConversation[this.selectedConversation.key].findIndex(mI => mI.id === m.id) === -1); // eslint-disable-line max-len
this.messagesByConversation[this.selectedConversation.key].push(...loadedMessagesToAdd);
// only show the load more Button if the max count was returned
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
this.messagesLoading = false;
},
},
};
</script>

View File

@@ -859,13 +859,12 @@ export default {
window.history.replaceState(null, null, ''); window.history.replaceState(null, null, '');
}, },
sendMessage () { sendMessage () {
this.$root.$emit('habitica::new-inbox-message', { this.$store.dispatch('user:newPrivateMessageTo', {
userIdToMessage: this.user._id, member: this.user,
displayName: this.user.profile.name,
username: this.user.auth.local.username,
backer: this.user.backer,
contributor: this.user.contributor,
}); });
this.$router.push('/private-messages');
this.$root.$emit('bv::hide::modal', 'profile');
}, },
getProgressDisplay () { getProgressDisplay () {
// let currentLoginDay = Content.loginIncentives[this.user.loginIncentives]; // let currentLoginDay = Content.loginIncentives[this.user.loginIncentives];

View File

@@ -29,7 +29,7 @@ export function setUpLogging () { // eslint-disable-line import/prefer-default-e
Vue.config.errorHandler = (err, vm, info) => { Vue.config.errorHandler = (err, vm, info) => {
console.error('Unhandled error in Vue.js code.'); console.error('Unhandled error in Vue.js code.');
console.error('Error:', err); console.error('Error:', err);
console.error('Component where it occured:', vm); console.error('Component where it occurred:', vm);
console.error('Info:', info); console.error('Info:', info);
_LTracker.push({ _LTracker.push({

View File

@@ -165,7 +165,7 @@ export default {
} }
return null; return null;
// @TOOD: User.sync(); // @TODO: User.sync();
}, },
castCancel () { castCancel () {
this.potionClickMode = false; this.potionClickMode = false;

View File

@@ -0,0 +1,867 @@
<template>
<div id="private-message">
<div class="floating-header-shadow"></div>
<div class="header-bar d-flex w-100">
<!-- changing w-25 would also need changes in .left-header.w-25 -->
<div class="d-flex w-25 left-header">
<div
v-once
class="mail-icon svg-icon"
v-html="icons.mail"
></div>
<h2
v-once
class="flex-fill text-center mail-icon-label"
>
{{ $t('messages') }}
</h2>
<div class="placeholder svg-icon">
<!-- placeholder -->
</div>
</div>
<div class="d-flex w-75 selected-conversion">
<face-avatar
v-if="selectedConversation.userStyles"
:member="selectedConversation.userStyles"
:class="selectedConversationFaceAvatarClass"
/>
<user-label
:backer="selectedConversation.backer"
:contributor="selectedConversation.contributor"
:name="selectedConversation.name"
:hide-tooltip="true"
/>
</div>
</div>
<div class="d-flex content">
<div class="w-25 sidebar d-flex flex-column">
<div class="disable-background">
<toggle-switch
:label="optTextSet.switchDescription"
:checked="this.user.inbox.optOut"
:hover-text="optTextSet.popoverText"
@change="toggleOpt()"
/>
</div>
<div class="search-section">
<b-form-input
v-model="search"
class="input-search"
:placeholder="$t('search')"
/>
</div>
<div
v-if="filtersConversations.length === 0"
class="empty-messages m-auto text-center empty-sidebar"
>
<div>
<div
v-once
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4 v-once>
{{ $t('emptyMessagesLine1') }}
</h4>
<p v-if="!user.flags.chatRevoked">
{{ $t('emptyMessagesLine2') }}
</p>
</div>
</div>
<div
v-if="filtersConversations.length > 0"
class="conversations"
>
<conversation-item
v-for="conversation in filtersConversations"
:key="conversation.key"
:active-key="selectedConversation.key"
:contributor="conversation.contributor"
:backer="conversation.backer"
:uuid="conversation.key"
:display-name="conversation.name"
:username="conversation.username"
:last-message-date="conversation.date"
:last-message-text="conversation.lastMessageText
? removeTags(parseMarkdown(conversation.lastMessageText)) : ''"
@click="selectConversation(conversation.key)"
/>
</div>
</div>
<div class="w-75 messages-column d-flex flex-column align-items-center">
<div
v-if="!selectedConversation.key"
class="empty-messages full-height m-auto text-center"
>
<div
v-once
class="svg-icon envelope"
v-html="icons.messageIcon"
></div>
<h4>{{ placeholderTexts.title }}</h4>
<p v-html="placeholderTexts.description"></p>
</div>
<div
v-if="selectedConversation.key && selectedConversationMessages.length === 0"
class="empty-messages full-height mt-auto text-center"
>
<avatar
v-if="selectedConversation.userStyles"
:member="selectedConversation.userStyles"
:avatar-only="true"
sprites-margin="0 0 0 -45px"
class="center-avatar"
/>
<h3>{{ $t('beginningOfConversation', {userName: selectedConversation.name}) }}</h3>
<p>{{ $t('beginningOfConversationReminder') }}</p>
</div>
<messageList
v-if="selectedConversation && selectedConversationMessages.length > 0"
ref="chatscroll"
class="message-scroll"
:chat="selectedConversationMessages"
:can-load-more="canLoadMore"
:is-loading="messagesLoading"
@message-removed="messageRemoved"
@triggerLoad="infiniteScrollTrigger"
/>
<div
v-if="user.inbox.optOut"
class="pm-disabled-caption text-center"
>
<h4>{{ $t('PMDisabledCaptionTitle') }}</h4>
<p>{{ $t('PMDisabledCaptionText') }}</p>
</div>
<div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="new-message-row d-flex align-items-center"
>
<textarea
v-model="newMessage"
class="flex-fill"
:placeholder="$t('needsTextPlaceholder')"
maxlength="3000"
:class="{'has-content': newMessage !== ''}"
@keyup.ctrl.enter="sendPrivateMessage()"
></textarea>
</div>
<div
v-if="selectedConversation.key && !user.flags.chatRevoked"
class="sub-new-message-row d-flex"
>
<div
v-once
class="guidelines flex-fill"
v-html="$t('communityGuidelinesIntro')"
></div>
<button
class="btn btn-primary"
:class="{'disabled':newMessage === ''}"
@click="sendPrivateMessage()"
>
{{ $t('send') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/variables.scss';
$pmHeaderHeight: 56px;
// Content of Private Message should be always full-size (minus the toolbar/resting banner)
#private-message {
height: calc(100vh - #{$menuToolbarHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)); // css variable magic :), must be 0px, 0 alone won't work
.content {
flex: 1;
height: calc(100vh - #{$menuToolbarHeight} - #{$pmHeaderHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)
);
}
.disable-background {
.toggle-switch-description {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
}
.toggle-switch-outer {
display: flex;
}
}
.modal-body {
padding: 0rem;
}
.modal-content {
width: 66vw;
}
.modal-dialog {
margin: 10vh 15vw 0rem;
}
.modal-header {
padding: 1rem 0rem;
.close {
cursor: pointer;
margin: 0rem 1.5rem;
min-width: 0.75rem;
padding: 0rem;
width: 0.75rem;
}
}
.toggle-switch-description {
font-size: 14px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.43;
letter-spacing: normal;
color: $gray-50;
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/tiers.scss';
@import '~@/assets/scss/variables.scss';
$pmHeaderHeight: 56px;
$background: $white;
.header-bar {
height: 56px;
background-color: $white;
padding-left: 1.5rem;
padding-right: 1.5rem;
align-items: center;
.mail-icon {
width: 32px;
height: 24px;
object-fit: contain;
}
.mail-icon-label {
margin-bottom: 0;
}
.placeholder.svg-icon {
width: 32px;
}
.left-header.w-25 {
width: calc(25% - 2rem) !important;
}
}
.full-height {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.user-label {
margin-left: 12px;
}
.input-search {
background-repeat: no-repeat;
background-position: center left 16px;
background-size: 16px 16px;
background-image: url(~@/assets/svg/for-css/search_gray.svg) !important;
padding-left: 40px;
color: $gray-200 !important;
height: 40px;
}
.selected-conversion {
justify-content: center;
align-items: center;
}
#private-message {
background-color: $background;
position: relative;
}
.disable-background {
height: 44px;
background-color: $gray-600;
padding: 0.75rem 1.5rem;
}
.conversations {
max-height: 35rem;
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
.empty-messages {
h3, h4, p {
color: $gray-400;
margin: 0rem;
}
p {
font-size: 12px;
}
.envelope {
width: 30px;
margin: 0 auto 0.5rem;
}
}
.envelope {
color: $gray-500 !important;
margin: 0rem;
max-width: 2rem;
}
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.header-wrap {
padding: 0.5em;
h2 {
margin: 0;
line-height: 1;
}
}
.messagePreview {
display: block;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.messages-column {
padding: 0rem;
display: flex;
flex-direction: column;
.empty-messages, .message-scroll {
flex: 1;
}
}
.message-scroll {
overflow-x: hidden;
padding-top: 0.5rem;
@media (min-width: 992px) {
overflow-x: hidden;
overflow-y: scroll;
}
}
.new-message-row {
width: 100%;
padding-left: 1.5rem;
padding-top: 1.5rem;
padding-right: 1.5rem;
textarea {
height: 5.5rem;
display: inline-block;
vertical-align: bottom;
border-radius: 2px;
z-index: 5;
border: solid 1px $gray-400;
opacity: 0.64;
background-color: $gray-500;
&:focus, &.has-content {
opacity: 1;
background-color: $white;
}
}
}
.sub-new-message-row {
padding: 1rem 1.5rem 1.5rem;
.guidelines {
height: 32px;
font-size: 12px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
color: $gray-200;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
button {
height: 40px;
border-radius: 2px;
margin-left: 1.5rem;
&.disabled {
cursor: default;
pointer-events: none;
opacity: 0.64;
background-color: $gray-500;
}
}
}
.pm-disabled-caption {
padding-top: 1em;
background-color: $gray-700;
z-index: 2;
h4, p {
color: $gray-300;
}
h4 {
margin-top: 0;
margin-bottom: 0.4em;
}
p {
font-size: 12px;
margin-bottom: 0;
}
}
.sidebar {
background-color: $gray-700;
min-height: 540px;
max-width: 330px;
padding: 0;
border-bottom-left-radius: 8px;
.search-section {
padding: 1rem 1.5rem;
border-bottom: 1px solid $gray-500;
}
}
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
.to-form input {
width: 60%;
display: inline-block;
margin-left: 1em;
}
.empty-sidebar {
display: flex;
align-items: center;
}
.floating-message-input {
background: $background;
position: fixed;
bottom: 0;
}
.floating-header-shadow {
position: absolute;
top: 0;
width: 100%;
height: 56px;
right: 0;
z-index: 1;
pointer-events: none;
box-shadow: 0 3px 12px 0 rgba(26, 24, 29, 0.24);
}
.center-avatar {
margin: 0 auto;
}
</style>
<script>
import Vue from 'vue';
import moment from 'moment';
import filter from 'lodash/filter';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import habiticaMarkdown from 'habitica-markdown';
import axios from 'axios';
import { mapState } from '@/libs/store';
import styleHelper from '@/mixins/styleHelper';
import toggleSwitch from '@/components/ui/toggleSwitch';
import userLabel from '@/components/userLabel';
import messageList from '@/components/messages/messageList';
import messageIcon from '@/assets/svg/message.svg';
import mail from '@/assets/svg/mail.svg';
import conversationItem from '@/components/messages/conversationItem';
import faceAvatar from '@/components/faceAvatar';
import Avatar from '@/components/avatar';
export default {
components: {
Avatar,
messageList,
toggleSwitch,
conversationItem,
userLabel,
faceAvatar,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper],
data () {
return {
icons: Object.freeze({
messageIcon,
mail,
}),
displayCreate: true,
selectedConversation: {},
search: '',
newMessage: '',
showPopover: false,
messages: [],
messagesByConversation: {}, // cache {uuid: []}
loadedConversations: [],
loaded: false,
messagesLoading: false,
initiatedConversation: null,
updateConversationsCounter: 0,
};
},
async mounted () {
this.$root.$on('pm::refresh', async () => {
await this.reload();
this.selectConversation(this.loadedConversations[0].uuid, true);
});
await this.reload();
const data = this.$store.state.privateMessageOptions;
if (data && data.userIdToMessage) {
this.initiatedConversation = {
uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
userStyles: data.userStyles,
};
this.$store.state.privateMessageOptions = {};
this.selectConversation(this.initiatedConversation.uuid);
}
},
destroyed () {
this.$root.$off('habitica::new-private-message');
},
computed: {
...mapState({ user: 'user.data' }),
canLoadMore () {
return this.selectedConversation && this.selectedConversation.canLoadMore;
},
conversations () {
const inboxGroup = groupBy(this.loadedConversations, 'uuid');
// 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,
contributor: this.initiatedConversation.contributor,
backer: this.initiatedConversation.backer,
userStyles: this.initiatedConversation.userStyles,
id: '',
text: '',
timestamp: new Date(),
}];
}
// Create conversation objects
const convos = [];
for (const key in inboxGroup) {
if (Object.prototype.hasOwnProperty.call(inboxGroup, key)) {
const recentMessage = inboxGroup[key][0];
const convoModel = {
key: recentMessage.uuid,
name: recentMessage.user,
// Handles case where from user sent the only message
// or the to user sent the only message
username: recentMessage.username,
date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
contributor: recentMessage.contributor,
userStyles: recentMessage.userStyles,
backer: recentMessage.backer,
canLoadMore: false,
page: 0,
};
convos.push(convoModel);
}
}
return convos;
},
// Separate from selectedConversation which is not computed
// so messages don't update automatically
/* eslint-disable vue/no-side-effects-in-computed-properties */
selectedConversationMessages () {
// Vue-subscribe to changes
const subscribeToUpdate = this.messagesLoading || this.updateConversationsCounter > -1;
const selectedConversationKey = this.selectedConversation.key;
const selectedConversation = this.messagesByConversation[selectedConversationKey];
this.messages = selectedConversation || [];
const ordered = orderBy(this.messages, [m => m.timestamp], ['asc']);
if (subscribeToUpdate) {
return ordered;
}
return [];
},
filtersConversations () {
// Vue-subscribe to changes
const subscribeToUpdate = this.updateConversationsCounter > -1;
const filtered = subscribeToUpdate && !this.search
? this.conversations
/* eslint-disable max-len */
: filter(this.conversations, conversation => conversation.name.toLowerCase().indexOf(this.search.toLowerCase()) !== -1);
const ordered = orderBy(filtered, [o => moment(o.date).toDate()], ['desc']);
return ordered;
},
currentLength () {
return this.newMessage.length;
},
placeholderTexts () {
if (this.user.flags.chatRevoked) {
return {
title: this.$t('PMPlaceholderTitleRevoked'),
description: this.$t('chatPrivilegesRevoked'),
};
}
return {
title: this.$t('PMPlaceholderTitle'),
description: this.$t('PMPlaceholderDescription'),
};
},
optTextSet () {
if (!this.user.inbox.optOut) {
return {
switchDescription: this.$t('PMDisabled'),
popoverText: this.$t('PMEnabledOptPopoverText'),
};
}
return {
switchDescription: this.$t('PMDisabled'),
popoverText: this.$t('PMDisabledOptPopoverText'),
};
},
selectedConversationFaceAvatarClass () {
if (this.selectedConversation && this.selectedConversation.contributor) {
return `tier${this.selectedConversation.contributor.level}`;
}
return '';
},
},
methods: {
async reload () {
this.loaded = false;
const conversationRes = await axios.get('/api/v4/inbox/conversations');
this.loadedConversations = conversationRes.data.data;
this.selectedConversation = {};
this.loaded = true;
},
messageRemoved (message) {
const messages = this.messagesByConversation[this.selectedConversation.key];
const messageIndex = messages.findIndex(msg => msg.id === message.id);
if (messageIndex !== -1) messages.splice(messageIndex, 1);
if (this.selectedConversationMessages.length === 0) {
this.initiatedConversation = {
uuid: this.selectedConversation.key,
user: this.selectedConversation.name,
username: this.selectedConversation.username,
backer: this.selectedConversation.backer,
contributor: this.selectedConversation.contributor,
};
}
},
toggleClick () {
this.displayCreate = !this.displayCreate;
},
toggleOpt () {
this.$store.dispatch('user:togglePrivateMessagesOpt');
},
async selectConversation (key, forceLoadMessage = false) {
const convoFound = this.conversations.find(conversation => conversation.key === key);
this.selectedConversation = convoFound || {};
if (!this.messagesByConversation[this.selectedConversation.key] || forceLoadMessage) {
await this.loadMessages();
}
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
},
sendPrivateMessage () {
if (!this.newMessage) return;
const messages = this.messagesByConversation[this.selectedConversation.key];
messages.push({
sent: true,
text: this.newMessage,
timestamp: new Date(),
toUser: this.selectedConversation.name,
toUserName: this.selectedConversation.username,
toUserContributor: this.selectedConversation.contributor,
toUserBacker: this.selectedConversation.backer,
toUUID: this.selectedConversation.uuid,
id: '-1', // will be updated once the result is back
likes: {},
ownerId: this.user._id,
uuid: this.user._id,
fromUUID: this.user._id,
user: this.user.profile.name,
username: this.user.auth.local.username,
contributor: this.user.contributor,
backer: this.user.backer,
});
// Remove the placeholder message
if (this.initiatedConversation
&& this.initiatedConversation.uuid === this.selectedConversation.key) {
this.loadedConversations.unshift(this.initiatedConversation);
this.initiatedConversation = null;
}
this.selectedConversation.lastMessageText = this.newMessage;
this.selectedConversation.date = new Date();
Vue.nextTick(() => {
if (!this.$refs.chatscroll) return;
const chatscroll = this.$refs.chatscroll.$el;
chatscroll.scrollTop = chatscroll.scrollHeight;
});
this.$store.dispatch('members:sendPrivateMessage', {
toUserId: this.selectedConversation.key,
message: this.newMessage,
}).then(response => {
const newMessage = response.data.data.message;
const messageToReset = messages[messages.length - 1];
messageToReset.id = newMessage.id; // just set the id, all other infos already set
Object.assign(messages[messages.length - 1], messageToReset);
this.updateConversationsCounter += 1;
});
this.newMessage = '';
},
removeTags (html) {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
},
parseMarkdown (text) {
if (!text) return null;
return habiticaMarkdown.render(String(text));
},
infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading)
if (this.canLoadMore) {
this.messagesLoading = true;
}
return this.loadMore();
},
loadMore () {
this.selectedConversation.page += 1;
return this.loadMessages();
},
async loadMessages () {
this.messagesLoading = true;
// use local vars if the loading takes longer
// and the user switches the conversation while loading
const conversationKey = this.selectedConversation.key;
const requestUrl = `/api/v4/inbox/paged-messages?conversation=${conversationKey}&page=${this.selectedConversation.page}`;
const res = await axios.get(requestUrl);
const loadedMessages = res.data.data;
this.messagesByConversation[conversationKey] = this.messagesByConversation[conversationKey] || [];
const loadedMessagesToAdd = loadedMessages
.filter(m => this.messagesByConversation[conversationKey].findIndex(mI => mI.id === m.id) === -1);
this.messagesByConversation[conversationKey].push(...loadedMessagesToAdd);
// only show the load more Button if the max count was returned
this.selectedConversation.canLoadMore = loadedMessages.length === 10;
this.messagesLoading = false;
},
},
};
</script>

View File

@@ -75,6 +75,8 @@ const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/com
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation'); const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing'); const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages');
// Challenges // Challenges
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index'); const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges'); const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges');
@@ -193,6 +195,7 @@ const router = new VueRouter({
}, },
], ],
}, },
{ path: '/private-messages', name: 'privateMessages', component: MessagesIndex },
{ {
name: 'challenges', name: 'challenges',
path: '/challenges', path: '/challenges',

View File

@@ -60,7 +60,10 @@ async function buyArmoire (store, params) {
const isExperience = item.type === 'experience'; const isExperience = item.type === 'experience';
if (item.type === 'gear') { if (item.type === 'gear') {
store.state.user.data.items.gear.owned[item.dropKey] = true; store.state.user.data.items.gear.owned = {
...store.state.user.data.items.gear.owned,
[item.dropKey]: true,
};
} }
if (item.type === 'food') { if (item.type === 'food') {

View File

@@ -160,3 +160,65 @@ export async function userLookup (store, params) {
} }
return response; return response;
} }
export function block (store, params) {
store.state.user.data.inbox.blocks.push(params.uuid);
return axios.post(`/api/v4/user/block/${params.uuid}`);
}
export function unblock (store, params) {
const index = store.state.user.data.inbox.blocks.indexOf(params.uuid);
store.state.user.data.inbox.blocks.splice(index, 1);
return axios.post(`/api/v4/user/block/${params.uuid}`);
}
export function newPrivateMessageTo (store, params) {
const { member } = params;
const userStyles = {};
userStyles.items = { gear: {} };
if (member.items) {
userStyles.items.gear = {};
userStyles.items.gear.costume = { ...member.items.gear.costume };
userStyles.items.gear.equipped = { ...member.items.gear.equipped };
userStyles.items.currentMount = member.items.currentMount;
userStyles.items.currentPet = member.items.currentPet;
}
if (member.preferences) {
userStyles.preferences = {};
if (member.preferences.style) userStyles.preferences.style = member.preferences.style;
userStyles.preferences.hair = member.preferences.hair;
userStyles.preferences.skin = member.preferences.skin;
userStyles.preferences.shirt = member.preferences.shirt;
userStyles.preferences.chair = member.preferences.chair;
userStyles.preferences.size = member.preferences.size;
userStyles.preferences.chair = member.preferences.chair;
userStyles.preferences.background = member.preferences.background;
userStyles.preferences.costume = member.preferences.costume;
}
if (member.stats) {
userStyles.stats = {};
userStyles.stats.class = member.stats.class;
if (member.stats.buffs) {
userStyles.stats.buffs = {
seafoam: member.stats.buffs.seafoam,
shinySeed: member.stats.buffs.shinySeed,
spookySparkles: member.stats.buffs.spookySparkles,
snowball: member.stats.buffs.snowball,
};
}
}
store.state.privateMessageOptions = {
userIdToMessage: member._id,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
userStyles,
};
}

View File

@@ -127,6 +127,13 @@ export default function () {
equipmentDrawerOpen: true, equipmentDrawerOpen: true,
groupPlans: [], groupPlans: [],
isRunningYesterdailies: false, isRunningYesterdailies: false,
privateMessageOptions: {
userIdToMessage: '',
displayName: '',
username: '',
backer: {},
contributor: {},
},
}, },
}); });

View File

@@ -112,7 +112,7 @@ context('avatar.vue', () => {
}; };
}); });
it('returns if showing equiped gear', () => { it('returns if showing equipped gear', () => {
expect(vm.costumeClass).to.equal('equipped'); expect(vm.costumeClass).to.equal('equipped');
}); });
it('returns if wearing a costume', () => { it('returns if wearing a costume', () => {

View File

@@ -116,6 +116,7 @@ module.exports = {
}, },
devServer: { devServer: {
disableHostCheck: true,
proxy: { proxy: {
// proxy all requests to the server at IP:PORT as specified in the top-level config // proxy all requests to the server at IP:PORT as specified in the top-level config
'^/api/v3': { '^/api/v3': {

View File

@@ -0,0 +1,9 @@
const path = require('path');
module.exports = {
resolve: {
alias: {
'@': path.join(__dirname, 'src'),
}
},
};

View File

@@ -34,5 +34,26 @@
"achievementMonsterMagusModalText": "Posbíral(a) jsi všechny zombie zvířata!", "achievementMonsterMagusModalText": "Posbíral(a) jsi všechny zombie zvířata!",
"achievementMonsterMagusText": "Posbíral(a) všechny zombie mazlíčky.", "achievementMonsterMagusText": "Posbíral(a) všechny zombie mazlíčky.",
"achievementMonsterMagus": "Monster mág", "achievementMonsterMagus": "Monster mág",
"achievementPartyOn": "Tvoje skupina se rozrostla na 4 členy!" "achievementPartyOn": "Tvoje skupina se rozrostla na 4 členy!",
"achievementPearlyProModalText": "Zkrotil jste všechny Bílé zvířata!",
"achievementPearlyProText": "Zkrotil všechny Bílé zvířata.",
"achievementPrimedForPaintingModalText": "Shromáždili jste všechny Bílé mazlíčky!",
"achievementPrimedForPaintingText": "Shromáždil všechny Bílé mazlíčky.",
"achievementPrimedForPainting": "Základem pro vybarveni",
"achievementPurchasedEquipmentModalText": "Vybavení je způsob, jak si přizpůsobit avatar a zlepšit své statistiky",
"achievementPurchasedEquipmentText": "Koupil první kus vybavení.",
"achievementPurchasedEquipment": "Nákup vybavení",
"achievementFedPetModalText": "Existuje mnoho různých druhů potravin, ale domácí mazlíčci mohou být vybíraví",
"achievementFedPetText": "Nakrmil/a svého prvního mazlíčka.",
"achievementFedPet": "Nakrmte domácího mazlíčka",
"achievementHatchedPetModalText": "Vydejte se do svého inventáře a zkuste zkombinovat lektvar a vejce",
"achievementCompletedTaskModalText": "Zaškrtněte některý ze svých úkolů a získejte odměny",
"achievementCompletedTaskText": "Dokončili svůj první úkol.",
"achievementCompletedTask": "Dokončete úkol",
"achievementCreatedTaskModalText": "Přidejte úkol pro něco, čeho byste chtěli tento týden dosáhnout",
"achievementCreatedTaskText": "Vytvořili svůj první úkol.",
"achievementCreatedTask": "Vytvořte úkol",
"earnedAchievement": "Získali jste úspěch!",
"viewAchievements": "Zobrazit úspěchy",
"letsGetStarted": "Začněme!"
} }

View File

@@ -437,5 +437,31 @@
"backgroundDuckPondNotes": "Nakrm vodní ptáky u kachního jezírka.", "backgroundDuckPondNotes": "Nakrm vodní ptáky u kachního jezírka.",
"backgroundDuckPondText": "Kachní jezírko", "backgroundDuckPondText": "Kachní jezírko",
"backgrounds032019": "Set 58: zveřejněno v březnu 2019", "backgrounds032019": "Set 58: zveřejněno v březnu 2019",
"backgroundValentinesDayFeastingHallNotes": "Pociť na Valentýna lásku v hodovní síni." "backgroundValentinesDayFeastingHallNotes": "Pociť na Valentýna lásku v hodovní síni.",
"backgroundWinterNocturneText": "Zimní serenáda",
"backgrounds122019": "SET 67: Vydáno v prosinci 2019",
"backgroundPotionShopText": "Obchod lektvarů",
"backgrounds112019": "SET 66: Vydáno v listopadu 2019",
"backgroundPumpkinCarriageText": "Dýňový vozík",
"backgrounds102019": "SET 65: Vydáno říjen 2019",
"backgroundInAClassroomText": "Třída",
"backgroundInAnAncientTombText": "Starověká hrobka",
"backgroundAutumnFlowerGardenText": "Podzimní květinová zahrada",
"backgrounds092019": "SET 64: Vydáno září 2019",
"backgroundTreehouseText": "Dům na stromě",
"backgroundGiantDandelionsText": "Obří pampelišky",
"backgroundAmidAncientRuinsText": "Uprostřed ruin",
"backgrounds082019": "SET 63: Vydáno srpen 2019",
"backgroundAmongGiantAnemonesNotes": "Prozkoumejte útesový život, chráněný před predátory Mezi obříma sasankama.",
"backgroundAmongGiantAnemonesText": "Mezi obříma sasankami",
"backgroundFlyingOverTropicalIslandsNotes": "Nechte si vyrazit pohledem dech, při přeletu přes Tropické Ostrovy.",
"backgroundFlyingOverTropicalIslandsText": "Létání nad tropickými ostrovy",
"backgroundLakeWithFloatingLanternsText": "Jezero s plovoucími lucernami",
"backgrounds072019": "SET 62: Vydáno červenec 2019",
"backgroundUnderwaterVentsNotes": "Potopte se hluboko dolů, dolů k podvodním otvorům.",
"backgroundUnderwaterVentsText": "Podvodní otvory",
"backgroundSeasideCliffsNotes": "Stůjte na pláži s krásou Přímořské útesů nad vámi.",
"backgroundSeasideCliffsText": "Přímořské útesy",
"backgroundSchoolOfFishNotes": "Plavejte mezi školou ryb.",
"backgroundSchoolOfFishText": "Škola ryb"
} }

View File

@@ -351,5 +351,7 @@
"questEggDolphinMountText": "Delfín", "questEggDolphinMountText": "Delfín",
"questEggDolphinText": "Delfín", "questEggDolphinText": "Delfín",
"hatchingPotionShadow": "Stín", "hatchingPotionShadow": "Stín",
"premiumPotionUnlimitedNotes": "Nepoužitelné na vejce z výprav." "premiumPotionUnlimitedNotes": "Nepoužitelné na vejce z výprav.",
"hatchingPotionAurora": "Polární záře",
"hatchingPotionAmber": "Jantar"
} }

View File

@@ -55,5 +55,9 @@
"workTodoProject": "Práce na projektu >> ukončena", "workTodoProject": "Práce na projektu >> ukončena",
"workDailyImportantTaskNotes": "Klikni k upřesnění tvého nejdůležitějšího úkolu", "workDailyImportantTaskNotes": "Klikni k upřesnění tvého nejdůležitějšího úkolu",
"workDailyImportantTask": "Nejdůležitější úkol >> pracoval(a) jsem na dnešním nejdůležitějším úkolu", "workDailyImportantTask": "Nejdůležitější úkol >> pracoval(a) jsem na dnešním nejdůležitějším úkolu",
"workHabitMail": "Zpracuj e-maily" "workHabitMail": "Zpracuj e-maily",
"exerciseDailyText": "Protahování >> Každodenní cvičební rutina",
"exerciseTodoText": "Nastavte rozvrh cvičení",
"exerciseTodoNotes": "Klepnutím přidáte kontrolní seznam!",
"healthHabit": "Jezte zdravé/nezdravé jídlo"
} }

View File

@@ -331,5 +331,6 @@
"getStarted": "Get Started!", "getStarted": "Get Started!",
"mobileApps": "Mobilní aplikace", "mobileApps": "Mobilní aplikace",
"learnMore": "Zjisti více", "learnMore": "Zjisti více",
"communityInstagram": "Instagram" "communityInstagram": "Instagram",
"minPasswordLength": "Heslo musí mít 8 nebo více znaků."
} }

View File

@@ -1809,5 +1809,8 @@
"weaponSpecialFall2019MageText": "Jednooká hůl", "weaponSpecialFall2019MageText": "Jednooká hůl",
"weaponSpecialFall2019WarriorText": "Pařátový trojzubec", "weaponSpecialFall2019WarriorText": "Pařátový trojzubec",
"weaponSpecialFall2019RogueText": "Notový pult", "weaponSpecialFall2019RogueText": "Notový pult",
"weaponSpecialSummer2019HealerText": "Bublinová hůlka" "weaponSpecialSummer2019HealerText": "Bublinová hůlka",
"armorSpecialSpring2019MageText": "Jantarová róba",
"armorSpecialSpring2019RogueText": "Mrakové brnění",
"weaponSpecialWinter2020RogueText": "Tyč lucerny"
} }

View File

@@ -294,5 +294,6 @@
"loadEarlierMessages": "Načíst dřívější zprávy", "loadEarlierMessages": "Načíst dřívější zprávy",
"demo": "Ukázka", "demo": "Ukázka",
"options": "Možnosti", "options": "Možnosti",
"finish": "Dokončit" "finish": "Dokončit",
"congratulations": "Gratulujeme!"
} }

View File

@@ -147,7 +147,7 @@
"dateEndJanuary": "Leden 31", "dateEndJanuary": "Leden 31",
"dateEndFebruary": "Únor 28", "dateEndFebruary": "Únor 28",
"winterPromoGiftHeader": "DARUJ PŘEDPLATNÉ A ZÍSKEJ JEDNO ZDARMA!", "winterPromoGiftHeader": "DARUJ PŘEDPLATNÉ A ZÍSKEJ JEDNO ZDARMA!",
"winterPromoGiftDetails1": "Až do 15. ledna, když někomu darujete předplatné, získáte stejné předplatné pro sebe zdarma!", "winterPromoGiftDetails1": "Až do 6. ledna, když někomu darujete předplatné, získáte stejné předplatné pro sebe zdarma!",
"winterPromoGiftDetails2": "Prosím, mějte však na paměti, že pokud ty nebo příjemce tvého dárku již máte probíhající předplatné, pak darované předplatné odstartuje až poté, co je stávající zrušeno, nebo až vyprší jeho platnost. Děkujeme ti moc za tvojí podporu! <3", "winterPromoGiftDetails2": "Prosím, mějte však na paměti, že pokud ty nebo příjemce tvého dárku již máte probíhající předplatné, pak darované předplatné odstartuje až poté, co je stávající zrušeno, nebo až vyprší jeho platnost. Děkujeme ti moc za tvojí podporu! <3",
"discountBundle": "balíček", "discountBundle": "balíček",
"g1g1Announcement": "Darujte předplatné, získejte akci zdarma na předplatné!", "g1g1Announcement": "Darujte předplatné, získejte akci zdarma na předplatné!",

View File

@@ -41,7 +41,7 @@
"messageAuthEmailTaken": "Email se již používá", "messageAuthEmailTaken": "Email se již používá",
"messageAuthNoUserFound": "Uživatel nenalezen.", "messageAuthNoUserFound": "Uživatel nenalezen.",
"messageAuthMustBeLoggedIn": "Musíš být přihlášen.", "messageAuthMustBeLoggedIn": "Musíš být přihlášen.",
"messageAuthMustIncludeTokens": "Ve svém požadavku musíš uvést token a uid (uživatelské id)", "messageAuthMustIncludeTokens": "Ve svém požadavku musíš uvést token a uid (Uživatelské Id)",
"messageGroupAlreadyInParty": "Již jsi ve skupině, zkus znovu načíst stránku.", "messageGroupAlreadyInParty": "Již jsi ve skupině, zkus znovu načíst stránku.",
"messageGroupOnlyLeaderCanUpdate": "Pouze velitel družiny může jí může upravovat!", "messageGroupOnlyLeaderCanUpdate": "Pouze velitel družiny může jí může upravovat!",
"messageGroupRequiresInvite": "Nemůžeš se přidat do družiny, do které nejsi pozván.", "messageGroupRequiresInvite": "Nemůžeš se přidat do družiny, do které nejsi pozván.",
@@ -62,5 +62,7 @@
"unallocatedStatsPoints": "Máš <span class=\"notification-bold-blue\"><%= points %>nepřidělených vlastnostních bodů</span>", "unallocatedStatsPoints": "Máš <span class=\"notification-bold-blue\"><%= points %>nepřidělených vlastnostních bodů</span>",
"beginningOfConversation": "Toto je začátek tvé konverzace s uživatelem <%= userName %>. Nezapomeň být milý, ucitvý a drž se směrnic komunity!", "beginningOfConversation": "Toto je začátek tvé konverzace s uživatelem <%= userName %>. Nezapomeň být milý, ucitvý a drž se směrnic komunity!",
"messageDeletedUser": "Omlouváme se, ale tento uživatel smazal svůj účet.", "messageDeletedUser": "Omlouváme se, ale tento uživatel smazal svůj účet.",
"messageMissingDisplayName": "Missing display name." "messageMissingDisplayName": "Chybí zobrazované jméno.",
"canDeleteNow": "Nyní můžete zprávu smazat.",
"reportedMessage": "Tuto zprávu jste nahlásili moderátorům."
} }

View File

@@ -20,7 +20,7 @@
"welcomeToTavern": "Vítej v Krčmě!", "welcomeToTavern": "Vítej v Krčmě!",
"sleepDescription": "Potřebuješ pauzu? Ubytuj se v Danielově krčmě pro pauznutí některých z těžších herních mechanismů země Habitica:", "sleepDescription": "Potřebuješ pauzu? Ubytuj se v Danielově krčmě pro pauznutí některých z těžších herních mechanismů země Habitica:",
"sleepBullet1": "Promeškané denní úkoly tě nezraní", "sleepBullet1": "Promeškané denní úkoly tě nezraní",
"sleepBullet2": "Úkoly neztratí sérii a barva zůstane nezměněna", "sleepBullet2": "Úkoly neztratí sérii",
"sleepBullet3": "Bossové ti neublíží za tvé vlastní zmeškané denní úkoly", "sleepBullet3": "Bossové ti neublíží za tvé vlastní zmeškané denní úkoly",
"sleepBullet4": "Tvé poškození bossům nebo sbírka předmětů na Výpravě zůstanou vypnuty, dokud se z krčmy neodhlásíš", "sleepBullet4": "Tvé poškození bossům nebo sbírka předmětů na Výpravě zůstanou vypnuty, dokud se z krčmy neodhlásíš",
"pauseDailies": "Pauznout poškození", "pauseDailies": "Pauznout poškození",
@@ -169,5 +169,6 @@
"imReady": "Vstup do země Habitica", "imReady": "Vstup do země Habitica",
"limitedOffer": "Dostupné do <%= date %>", "limitedOffer": "Dostupné do <%= date %>",
"paymentCanceledDisputes": "Na váš e-mail jsme zaslali potvrzení o zrušení. Pokud e-mail nevidíte, kontaktujte nás, abychom předešli budoucím sporům o fakturaci.", "paymentCanceledDisputes": "Na váš e-mail jsme zaslali potvrzení o zrušení. Pokud e-mail nevidíte, kontaktujte nás, abychom předešli budoucím sporům o fakturaci.",
"paymentAutoRenew": "Toto předplatné se automaticky obnoví, dokud nebude zrušeno. Pokud potřebujete předplatné zrušit, můžete tak učinit z nastavení." "paymentAutoRenew": "Toto předplatné se automaticky obnoví, dokud nebude zrušeno. Pokud potřebujete předplatné zrušit, můžete tak učinit z nastavení.",
"cannotUnpinItem": "Tuto položku nelze odepnout."
} }

View File

@@ -639,5 +639,12 @@
"questBronzeDropBronzePotion": "Bronzový líhnoucí lektvar", "questBronzeDropBronzePotion": "Bronzový líhnoucí lektvar",
"questBronzeBoss": "Brazen Brouk", "questBronzeBoss": "Brazen Brouk",
"questBronzeText": "Bitva s Brazenem Broukem", "questBronzeText": "Bitva s Brazenem Broukem",
"mythicalMarvelsText": "Balíček mýtických zázraků" "mythicalMarvelsText": "Balíček mýtických zázraků",
"questRobotCollectBolts": "Šrouby",
"questRobotCollectGears": "Ozubená kola",
"questRobotCollectSprings": "Pružiny",
"questRobotDropRobotEgg": "Robot (Vejce)",
"questAmberText": "Jantarová Aliance",
"questAmberBoss": "Trerezin",
"questAmberDropAmberPotion": "Jantarový líhnoucí lektvar"
} }

View File

@@ -21,7 +21,7 @@
"rebirthOrb": "Použil Kouli Znovozrození, aby začal znova, po dosáhnutí úrovně <%= level %>.", "rebirthOrb": "Použil Kouli Znovozrození, aby začal znova, po dosáhnutí úrovně <%= level %>.",
"rebirthOrb100": "Použil Kouli znovuzrození, aby začal odznovu po dosažení úrovně 100 nebo vyšší.", "rebirthOrb100": "Použil Kouli znovuzrození, aby začal odznovu po dosažení úrovně 100 nebo vyšší.",
"rebirthOrbNoLevel": "Použil Kouli Znovozrození, aby začal znova.", "rebirthOrbNoLevel": "Použil Kouli Znovozrození, aby začal znova.",
"rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, jen u úkolů z výzev zůstanou. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností. Pro více informací se podívej na wiki stránku: <a href='http://habitica.fandom.com/wiki/Orb_of_Rebirth' target='_blank'>Orb of Rebirth</a>.", "rebirthPop": "Obnoví tvou postavu a vrátí jí na 1. úroveň s povoláním Válečníka, zatímco ti zůstanou všechny úspěchy, celá sbírka a vybavení. Tvoje úkoly zůstanou i s historií, ale vrátí se na žlutou barvu. Tvé řady úspěchů se resetují, zůstanou úkoly patřící do aktivních výzev a do Skupiny. Tvé zlato, zkušenosti, mana a efekty všech schopností budou odstraněny. Toto vše nastane s okamžitou platností. Pro více informací se podívej na wiki stránku: <a href='http://habitica.fandom.com/wiki/Orb_of_Rebirth' target='_blank'>Orb of Rebirth</a>.",
"rebirthName": "Koule znovuzrození", "rebirthName": "Koule znovuzrození",
"reborn": "Znovuzrozen, maximální úroveň <%= reLevel %>", "reborn": "Znovuzrozen, maximální úroveň <%= reLevel %>",
"confirmReborn": "Jsi si jistý?", "confirmReborn": "Jsi si jistý?",

View File

@@ -174,7 +174,7 @@
"missingUnsubscriptionCode": "Chybějící kód k zrušení předplatného.", "missingUnsubscriptionCode": "Chybějící kód k zrušení předplatného.",
"missingSubscription": "Uživatel nemá předplatné", "missingSubscription": "Uživatel nemá předplatné",
"missingSubscriptionCode": "Chybějící kód předplatitele. Možné hodnoty: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", "missingSubscriptionCode": "Chybějící kód předplatitele. Možné hodnoty: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.",
"missingReceipt": "Missing Receipt.", "missingReceipt": "Chybějící potvrzení.",
"cannotDeleteActiveAccount": "Máte aktivní předplatné, zrušte si ho před smazáním účtu.", "cannotDeleteActiveAccount": "Máte aktivní předplatné, zrušte si ho před smazáním účtu.",
"paymentNotSuccessful": "Zaplacení neproběhlo úspešně", "paymentNotSuccessful": "Zaplacení neproběhlo úspešně",
"planNotActive": "Předplatné ještě nebylo aktivováén (vzhledem k PayPal bugu). Bude aktivováno <%= nextBillingDate %>, poté ho můžete zrušit a obdržíte veškeré výhody", "planNotActive": "Předplatné ještě nebylo aktivováén (vzhledem k PayPal bugu). Bude aktivováno <%= nextBillingDate %>, poté ho můžete zrušit a obdržíte veškeré výhody",

View File

@@ -2,29 +2,66 @@
"achievement": "Præstation", "achievement": "Præstation",
"share": "Del", "share": "Del",
"onwards": "Fremad!", "onwards": "Fremad!",
"levelup": "Ved at opnå dine mål fra den virkelige verden er du steget i level, og er nu fuldt helet igen!", "levelup": "Ved at opnå dine mål fra den virkelige verden er du steget i niveau, og er nu fuldt helet igen!",
"reachedLevel": "Du har nået level <%= level %>", "reachedLevel": "Du har nået niveau <%= level %>",
"achievementLostMasterclasser": "Quest færdiggører: Mesterklasse-rækken", "achievementLostMasterclasser": "Quest-knuser: Mesterklasse-serien",
"achievementLostMasterclasserText": "Færdiggjorde alle 16 quests i Mesterklasse quest-rækken og løste alle mysterier fra \"the Lost Masterclasser\"!", "achievementLostMasterclasserText": "Færdiggjorde alle 16 quests i Mesterklasse-questrækken, og løste mysteriet om den Forsvundne Mesterklasser!",
"achievementLostMasterclasserModalText": "Du har klaret alle seksten quests i Mesterklasser-serien og løst mysteriet om den forsvundne Mesterklasser!", "achievementLostMasterclasserModalText": "Du har klaret alle seksten quests i Mesterklasse-serien og løst mysteriet om den Forsvundne Mesterklasser!",
"achievementMindOverMatterText": "Har fuldført sten-, slim- og garn-kæledyrsquests.", "achievementMindOverMatterText": "Har fuldført sten-, slim- og garn-kæledyrsquests.",
"achievementMindOverMatterModalText": "Du har klaret sten-, slim-, og garn-kæledyrsquestene!", "achievementMindOverMatterModalText": "Du har klaret sten-, slim-, og garn-kæledyrsquestene!",
"achievementJustAddWater": "Tilføj kun vand", "achievementJustAddWater": "Tilføj kun vand",
"achievementJustAddWaterText": "Har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene.", "achievementJustAddWaterText": "Har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene.",
"achievementJustAddWaterModalText": "Du har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene!", "achievementJustAddWaterModalText": "Du har klaret blæksprutte-, søheste-, tiarmet blæksprutte-, hval-, skildpadde-, nøgensnegle-, søslange- og delfin-kæledyrsquestene!",
"achievementBackToBasics": "Almindeligt udbredt", "achievementBackToBasics": "Almindeligt udbredt",
"achievementBackToBasicsText": "Har samlet alle Almindelige kæledyr.", "achievementBackToBasicsText": "Har samlet alle almindelige kæledyr.",
"achievementBackToBasicsModalText": "Du har samlet alle Almindelige kæledyr!", "achievementBackToBasicsModalText": "Du har samlet alle almindelige kæledyr!",
"achievementAllYourBase": "Alle almindelige", "achievementAllYourBase": "Alle almindelige",
"achievementAllYourBaseText": "Har tæmmet alle Almindelige ridedyr.", "achievementAllYourBaseText": "Har tæmmet alle almindelige ridedyr.",
"achievementAllYourBaseModalText": "Du har tæmmet alle Almindelige ridedyr!", "achievementAllYourBaseModalText": "Du har tæmmet alle almindelige ridedyr!",
"achievementMonsterMagusModalText": "Du har samlet all zombie dyr!", "achievementMonsterMagusModalText": "Du har samlet all zombiekæledyr!",
"achievementMonsterMagusText": "Har samlet all zombie dyr.", "achievementMonsterMagusText": "Har samlet all zombiekæledyr.",
"achievementPartyOn": "Dit hold vokset til 4 medlemmer!", "achievementPartyOn": "Dit hold voksede til 4 medlemmer!",
"achievementAridAuthorityModalText": "Du har tæmmet all ørken dyr!", "achievementAridAuthorityModalText": "Du har tæmmet all ørkenridedyr!",
"achievementAridAuthorityText": "Har tæmmet all ørken dyr.", "achievementAridAuthorityText": "Har tæmmet all ørkenridedyr.",
"achievementDustDevilModalText": "Du har samlet alle ørken dyr!", "achievementDustDevilModalText": "Du har samlet alle ørkenkæledyr!",
"achievementDustDevilText": "Har samlet alle ørken dyr.", "achievementDustDevilText": "Har samlet alle ørkenkæledyr.",
"achievementDustDevil": "Støvdjævel", "achievementDustDevil": "Støvdjævel",
"achievementMindOverMatter": "Hvor der er vilje..." "achievementMindOverMatter": "Stof til eftertanke",
"achievementPearlyProModalText": "Du har tæmmet alle de hvide ridedyr!",
"achievementPearlyProText": "Har tæmmet alle hvide ridedyr.",
"achievementPearlyPro": "Perleskinnende prof",
"achievementPrimedForPaintingModalText": "Du har samlet alle de hvide kæledyr!",
"achievementPrimedForPaintingText": "Har samlet all hvide kæledyr.",
"achievementPrimedForPainting": "Grundmalet gruppe",
"achievementPurchasedEquipmentModalText": "Udstyr er en måde at tilpasse din avatar og forbedre dine egenskaber",
"achievementPurchasedEquipmentText": "Købte deres første stykke udstyr.",
"achievementPurchasedEquipment": "Køb udstyr",
"achievementFedPetModalText": "Der er mange forskellige slags mad, men kæledyr kan være kræsne",
"achievementFedPetText": "Fodrede deres første kæledyr.",
"achievementFedPet": "Giv et kæledyr mad",
"achievementHatchedPetModalText": "Kig i dit inventar, og prøv at kombinere en udrugningseliksir og et æg",
"achievementHatchedPetText": "Udklækkede deres første kæledyr.",
"achievementHatchedPet": "Udklæk et kæledyr",
"achievementCompletedTaskModalText": "Marker enhver af dine opgaver som færdig for at få belønninger",
"achievementCompletedTaskText": "Udførte deres første opgave.",
"achievementCompletedTask": "Udfør en opgave",
"achievementCreatedTaskModalText": "Tilføj en opgave for noget, du gerne vil gøre i denne uge",
"achievementCreatedTaskText": "Oprettede deres første opgave.",
"achievementCreatedTask": "Opret en opgave",
"achievementUndeadUndertakerModalText": "Du har tæmmet alle zombieridedyr!",
"achievementUndeadUndertakerText": "Har tæmmet alle zombieridedyr.",
"achievementUndeadUndertaker": "Udød yndling",
"achievementMonsterMagus": "Monstrøs magiker",
"achievementKickstarter2019Text": "Sponsorerede Kickstarterprojektet for badges i 2019",
"achievementKickstarter2019": "Pin Kickstarter sponsor",
"achievementAridAuthority": "Knastør ekspert",
"achievementPartyUp": "Du dannede hold med en anden bruger!",
"hideAchievements": "Skjul <%= category %>",
"showAllAchievements": "Vis alle <%= category %>",
"onboardingCompleteDesc": "Du opnåede <strong>5 præstationer</strong> og <strong class=\"gold-amount\">100</strong> guld for at færdiggøre listen.",
"earnedAchievement": "Du opnåede en præstation!",
"viewAchievements": "Se præstationer",
"letsGetStarted": "Lad os komme i gang!",
"onboardingProgress": "<%= percentage %>% fremskridt",
"gettingStartedDesc": "Lad os lave en opgave, udføre den, og så se på din belønning. You vil få <strong>5 præstationer</strong> og <strong class=\"gold-amount\">100 guld</strong>, når du er færdig!"
} }

View File

@@ -1,13 +1,13 @@
{ {
"defaultHabit1Text": "Produktivt arbejde (Klik på blyanten for at redigere)", "defaultHabit1Text": "Produktivt arbejde (Klik på blyanten for at redigere)",
"defaultHabit1Notes": "Eksempler på Gode Vaner: +Spist en grønsag +15 minutters produktivt arbejde", "defaultHabit1Notes": "Eksempler på gode vaner: + Spis en grønsag + 15 minutters produktivt arbejde",
"defaultHabit2Text": "Spis junk food (Klik på blyanten for at redigere)", "defaultHabit2Text": "Spis junk food (Klik på blyanten for at redigere)",
"defaultHabit2Notes": "Eksempler på Dårlige Vaner: -Ryge -Overspringshandling", "defaultHabit2Notes": "Eksempler på dårlige vaner: - Rygning - Lav overspringshandlinger",
"defaultHabit3Text": "Tag trapperne/elevator (Klik på blyanten for at redigere)", "defaultHabit3Text": "Tag trapperne/elevatoren (Klik på blyanten for at redigere)",
"defaultHabit3Notes": "Eksempel på God eller Dårlig Vane: +/- Tog Trapperne/Elevatoren; +/- Drak Vand/Sodavand", "defaultHabit3Notes": "Eksempel på en god eller dårlig vane: +/- Tog trapperne/elevatoren; +/- Drak vand/sodavand",
"defaultHabit4Text": "Tilføj en opgave til Habitica", "defaultHabit4Text": "Tilføj en opgave til Habitica",
"defaultHabit4Notes": "Enten en Vane, en Daglig eller en To-Do", "defaultHabit4Notes": "Enten en Vane, en Daglig eller en To-Do",
"defaultHabit5Text": "Tryk her for at gøre dette til en dårlig vane du gerne vil af med", "defaultHabit5Text": "Tryk her, for at gøre dette til en dårlig vane, du gerne vil af med",
"defaultHabit5Notes": "Eller slet fra redigeringsskærmen", "defaultHabit5Notes": "Eller slet fra redigeringsskærmen",
"defaultDaily1Text": "Brug Habitica til at holde styr på dine opgaver", "defaultDaily1Text": "Brug Habitica til at holde styr på dine opgaver",
"defaultTodo1Text": "Start med at spille Habitica (Markér mig som færdig!)", "defaultTodo1Text": "Start med at spille Habitica (Markér mig som færdig!)",

View File

@@ -140,59 +140,59 @@
"subscriptionRateText": "Løbende $<%= price %> USD hver <%= months %>. måned", "subscriptionRateText": "Løbende $<%= price %> USD hver <%= months %>. måned",
"recurringText": "løbende", "recurringText": "løbende",
"benefits": "Fordele", "benefits": "Fordele",
"coupon": "Kupon", "coupon": "Rabat",
"couponPlaceholder": "Indtast Kuponkode", "couponPlaceholder": "Indtast rabatkode",
"couponText": "Nogle gange har vi events og giver kuponkoder til specielt udstyr. (f.eks. til dem, der svinger forbi vores stand på Wondercon)", "couponText": "Nogle gange har vi events og giver rabatkoder til specielt udstyr (f.eks. til dem, der svinger indenom vores stand på Wondercon).",
"apply": "Udfør", "apply": "Udfør",
"resubscribe": "Genabonnér", "resubscribe": "Genabonnér",
"promoCode": "Promo-kode", "promoCode": "Promo-kode",
"promoCodeApplied": "Promo-kode anvendt. Tjek dit inventar", "promoCodeApplied": "Promo-kode accepteret! Tjek dit inventar",
"promoPlaceholder": "Indtast Promo-kode", "promoPlaceholder": "Indtast promo-kode",
"displayInviteToPartyWhenPartyIs1": "Vis Invitér til Hold-knap når holdet har 1 medlem.", "displayInviteToPartyWhenPartyIs1": "Vis Invitér til Hold-knap når holdet har 1 medlem.",
"saveCustomDayStart": "Gem Brugerdefineret Dagstart", "saveCustomDayStart": "Gem brugerdefineret starttidspunkt",
"registration": "Registrering", "registration": "Registrering",
"addLocalAuth": "Tilføj login med email og kodeord", "addLocalAuth": "Tilføj login med email og kodeord",
"generateCodes": "Generér Koder", "generateCodes": "Generér koder",
"generate": "Generér", "generate": "Generér",
"getCodes": "Hent Koder", "getCodes": "Hent koder",
"webhooks": "Webhooks", "webhooks": "Webhooks",
"webhooksInfo": "Habitica tilbyder webhooks, så information kan blive sendt til et script på en anden hjemmeside, når der sker bestemte ting med din konto. Du kan specificere disse scripts her. Vær forsigtig med denne funktion. En forkert URL kan forårsage fejl eller gøre Habitica langsomt. Se wiki-siden <a target=\"_blank\" href=\"https://habitica.fandom.com/wiki/Webhooks\">Webhooks</a> for mere information (engelsk).", "webhooksInfo": "Habitica tilbyder webhooks, så information kan blive sendt til et script på en anden hjemmeside, når der sker bestemte ting med din konto. Du kan specificere disse scripts her. Vær forsigtig med denne funktion. En forkert URL kan forårsage fejl eller gøre Habitica langsom. Se wiki-siden <a target=\"_blank\" href=\"https://habitica.fandom.com/wiki/Webhooks\">Webhooks</a> (EN) for mere information.",
"enabled": "Aktiveret", "enabled": "Aktiveret",
"webhookURL": "Webhook URL", "webhookURL": "Webhook URL",
"invalidUrl": "ugyldig url", "invalidUrl": "ugyldig url",
"invalidEnabled": "Parametren \"aktiveret\" skal være en boolesk værdi.", "invalidEnabled": "Parametret \"aktiveret\" skal være en boolesk værdi.",
"invalidWebhookId": "Parametren \"id\" skal være et gyldigt Unikt Bruger-ID.", "invalidWebhookId": "Parametret \"id\" skal være et gyldigt unikt bruger-ID.",
"missingWebhookId": "Webhook-id påkrævet.", "missingWebhookId": "Webhook-ID påkrævet.",
"invalidWebhookType": "\"<%= type %>\" er ikke en gyldig værdi for parametren \"type\".", "invalidWebhookType": "\"<%= type %>\" er ikke en gyldig værdi for parametret \"type\".",
"webhookBooleanOption": "\"<%= option %>\" skal være en boolesk værdi.", "webhookBooleanOption": "\"<%= option %>\" skal være en boolesk værdi.",
"webhookIdAlreadyTaken": "En webhook med id'et <%= id %> findes allerede.", "webhookIdAlreadyTaken": "En webhook med ID'et <%= id %> findes allerede.",
"noWebhookWithId": "Der er ingen webhook med id'et <%= id %>.", "noWebhookWithId": "Der er ingen webhook med ID'et <%= id %>.",
"regIdRequired": "RegId påkrævet", "regIdRequired": "RegID påkrævet",
"invalidPushClient": "Ugyldig klient. Kun officielle Habitica-klienter kan modtage pushnotifikationer.", "invalidPushClient": "Ugyldig klient. Kun officielle Habitica-klienter kan modtage pushnotifikationer.",
"pushDeviceAdded": "Push-enhed tilføjet", "pushDeviceAdded": "Push-enhed tilføjet",
"pushDeviceAlreadyAdded": "Denne bruger har allerede push-enheden", "pushDeviceAlreadyAdded": "Denne bruger har allerede push-enheden",
"pushDeviceNotFound": "Brugeren har ingen push-enhed med dette ID.", "pushDeviceNotFound": "Brugeren har ingen push-enhed med dette ID.",
"pushDeviceRemoved": "Push-enhed fjernet.", "pushDeviceRemoved": "Push-enhed fjernet.",
"buyGemsGoldCap": "Maksimum hævet til <%= amount %>", "buyGemsGoldCap": "Maksimum hævet til <%= amount %>",
"mysticHourglass": "<%= amount %> Mystiske Timeglas", "mysticHourglass": "<%= amount %> mystiske timeglas",
"mysticHourglassText": "Mystiske Timeglas giver dig adgang til at købe tidligere måneders Mystiske Gendstande-sæt.", "mysticHourglassText": "Mystiske timeglas giver dig adgang til at købe mystiske sæt fra tidligere måneder.",
"purchasedPlanId": "Løbende $<%= price %> USD hver <%= months %>. måned (<%= plan %>)", "purchasedPlanId": "Løbende $<%= price %> USD hver <%= months %>. måned (<%= plan %>)",
"purchasedPlanExtraMonths": "Du har <%= months %> måneders overskydende abonnementkredit.", "purchasedPlanExtraMonths": "Du har <%= months %> måneders overskydende abonnementkredit.",
"consecutiveSubscription": "Fortløbende Abonnement", "consecutiveSubscription": "Fortløbende abonnement",
"consecutiveMonths": "Fortløbende Måneder:", "consecutiveMonths": "Fortløbende måneder:",
"gemCapExtra": "Ekstra Ædelstensmaksimum:", "gemCapExtra": "Ekstra ædelstensmaksimum:",
"mysticHourglasses": "Mystiske Timeglas:", "mysticHourglasses": "Mystiske timeglas:",
"mysticHourglassesTooltip": "Mystiske timeglas", "mysticHourglassesTooltip": "Mystiske timeglas",
"paypal": "PayPal", "paypal": "PayPal",
"amazonPayments": "Amazon Payments", "amazonPayments": "Amazon Payments",
"amazonPaymentsRecurring": "Det er nødvendigt at sætte hak i boksen nedenunder for at oprette dit abonnement. Det vil tillade at din Amazonkonto kan bruges til gentagne betalinger for <strong>dette</strong> abonnement. Det vil ikke få til Amazonkonto til at blive brugt automatisk ved fremtidige køb.", "amazonPaymentsRecurring": "Det er nødvendigt at sætte hak i boksen nedenunder for at oprette dit abonnement. Det vil tillade, at din Amazonkonto kan bruges til gentagne betalinger for <strong>dette</strong> abonnement. Det vil ikke forårsage, at din Amazonkonto bliver brugt automatisk ved fremtidige køb.",
"timezone": "Tidszone", "timezone": "Tidszone",
"timezoneUTC": "Habitica bruger tidszonen sat på din PC, som er: <strong><%= utc %></strong>", "timezoneUTC": "Habitica bruger tidszonen fra din PC, som er: <strong><%= utc %></strong>",
"timezoneInfo": "Hvis tidszonen er forkert, kan du først genindlæse denne side ved hjælp af din browsers knap til genindlæsning for at give Habitica de mest opdaterede informationer. Hvis den stadig er forkert, kan du justere din tidszone på din PC og derefter genindlæse siden igen.<br><br> <strong>Hvis du bruger Habitica på andre PC'er eller mobile enheder, skal tidszonen være den samme på dem alle.</strong> Hvis dine Daglige er blevet nulstellet på det forkerte tidspunkt. kan du gentage denne gennemgang alle andre PC'er og i en browser på dine mobile enheder.", "timezoneInfo": "Hvis tidszonen er forkert, kan du først genindlæse denne side, ved hjælp af din browsers knap til genindlæsning, for at give Habitica de mest opdaterede informationer. Hvis den stadig er forkert, kan du justere din tidszone på din PC, og derefter genindlæse siden igen.<br><br> <strong>Hvis du bruger Habitica på andre PC'er eller mobile enheder, skal tidszonen være den samme på dem alle.</strong> Hvis dine Daglige er blevet nulstillet på det forkerte tidspunkt. gentag venligst denne gennemgang af alle PC'er og i en browser på alle mobile enheder.",
"push": "Push", "push": "Push",
"about": "Om Habitica", "about": "Om Habitica",
"setUsernameNotificationTitle": "Bekræft dit brugernavn!", "setUsernameNotificationTitle": "Bekræft dit brugernavn!",
"setUsernameNotificationBody": "Vi vil skifte loginnavne til unikke, offentlige brugernavne snart. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.", "setUsernameNotificationBody": "Vi ændrer snart loginnavne til unikke, offentlige brugernavne. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
"usernameIssueSlur": "Brugernavne må ikke indeholde upassende sprogbrug.", "usernameIssueSlur": "Brugernavne må ikke indeholde upassende sprogbrug.",
"usernameIssueForbidden": "Brugernavne må ikke indeholde blokerede ord.", "usernameIssueForbidden": "Brugernavne må ikke indeholde blokerede ord.",
"usernameIssueLength": "Brugernavne skal være mellem 1 og 20 tegn.", "usernameIssueLength": "Brugernavne skal være mellem 1 og 20 tegn.",
@@ -200,9 +200,13 @@
"currentUsername": "Nuværende brugernavn:", "currentUsername": "Nuværende brugernavn:",
"displaynameIssueLength": "Displaynavne skal indeholde mellem 1 og 30 tegn.", "displaynameIssueLength": "Displaynavne skal indeholde mellem 1 og 30 tegn.",
"displaynameIssueSlur": "Displaynavne må ikke indeholde upassende sprogbrug.", "displaynameIssueSlur": "Displaynavne må ikke indeholde upassende sprogbrug.",
"goToSettings": "Gå til Indstillinger", "goToSettings": "Gå til indstillinger",
"usernameVerifiedConfirmation": "Dit brugernavn, <%= username %>, er blevet bekræftet!", "usernameVerifiedConfirmation": "Dit brugernavn, <%= username %>, er blevet bekræftet!",
"usernameNotVerified": "Bekræft venligst dit brugernavn.", "usernameNotVerified": "Bekræft venligst dit brugernavn.",
"changeUsernameDisclaimer": "Vi vil lave loginnavne om til unikke, offentlige brugernavne snart. Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.", "changeUsernameDisclaimer": "Dette brugernavn vil blive brugt til invitationer, @tags i chat og beskeder.",
"verifyUsernameVeteranPet": "Et af disse Veterankæledyr venter på dig når du er færdig med at bekræfte!" "verifyUsernameVeteranPet": "Et af disse veterankæledyr venter på dig, når du er færdig med at bekræfte!",
"onlyPrivateSpaces": "Kun i private rum",
"everywhere": "Alle steder",
"suggestMyUsername": "Foreslå mit brugernavn",
"mentioning": "Tagging"
} }

View File

@@ -2,7 +2,7 @@
"achievement": "Erfolg", "achievement": "Erfolg",
"share": "Teilen", "share": "Teilen",
"onwards": "Auf geht's!", "onwards": "Auf geht's!",
"levelup": "Durch das Erfüllen Deiner Ziele im Leben bist Du ein Level aufgestiegen und wurdest vollständig geheilt!", "levelup": "Durch das Erfüllen Deiner Ziele im realen Leben bist Du ein Level aufgestiegen und wurdest vollständig geheilt!",
"reachedLevel": "Du hast Level <%= level %> erreicht", "reachedLevel": "Du hast Level <%= level %> erreicht",
"achievementLostMasterclasser": "Quest-Erfüller: Klassenmeister-Reihe", "achievementLostMasterclasser": "Quest-Erfüller: Klassenmeister-Reihe",
"achievementLostMasterclasserText": "Hat alle sechzehn Quests in der Klassenmeister-Questreihe abgeschlossen und das Rätsel des Verschwundenen Klassenmeisters gelöst!", "achievementLostMasterclasserText": "Hat alle sechzehn Quests in der Klassenmeister-Questreihe abgeschlossen und das Rätsel des Verschwundenen Klassenmeisters gelöst!",
@@ -40,5 +40,28 @@
"achievementPrimedForPaintingText": "Hat alle weißen Haustiere gesammelt.", "achievementPrimedForPaintingText": "Hat alle weißen Haustiere gesammelt.",
"achievementPearlyProText": "Hat alle weißen Reittiere gezähmt.", "achievementPearlyProText": "Hat alle weißen Reittiere gezähmt.",
"achievementPearlyPro": "Perlweiß-Profi", "achievementPearlyPro": "Perlweiß-Profi",
"achievementPrimedForPainting": "Grundierung gemalt" "achievementPrimedForPainting": "Grundierung gemalt",
"achievementCreatedTask": "Erstelle eine Aufgabe",
"onboardingCompleteDesc": "Du hast durch das Abschließen der Liste <strong>5 Erfolge</strong> freigeschaltet und <strong class=\"gold-amount\">100</strong> Goldstücke verdient.",
"earnedAchievement": "Du hast einen Erfolg freigeschaltet!",
"achievementPurchasedEquipmentModalText": "Durch Ausrüstung kann Dein Avatar personalisiert und die Attributswerte erhöht werden",
"achievementPurchasedEquipment": "Erwerbe Ausrüstung",
"achievementFedPetModalText": "Es gibt viele verschiedene Arten an Futter, aber Haustiere können wählerisch sein",
"achievementFedPet": "Füttere ein Haustier",
"achievementHatchedPet": "Schlüpfe ein Haustier",
"hideAchievements": "<%= category %> ausblenden",
"showAllAchievements": "Alle <%= category %> anzeigen",
"viewAchievements": "Erfolge ansehen",
"letsGetStarted": "Los geht's!",
"onboardingProgress": "<%= percentage %>% Fortschritt",
"gettingStartedDesc": "Erstelle eine Aufgabe, schließe sie ab und prüfe dann Deine Belohnungen. Du schaltest <strong>5 Erfolge</strong> frei und erhältst <strong class=\"gold-amount\">100 Goldstücke</strong>, sobald Du fertig bist!",
"achievementCreatedTaskModalText": "Füge eine Aufgabe hinzu, für etwas, das Du diese Woche erreichen willst",
"achievementPurchasedEquipmentText": "Hat den ersten Rüstungsgegenstand gekauft.",
"achievementFedPetText": "Hat das erste Haustier gefüttert.",
"achievementHatchedPetModalText": "Schau in Dein Inventar und versuche ein Schlüpfelixier mit einem Ei zu kombinieren",
"achievementHatchedPetText": "Hat das erste Haustier geschlüpft.",
"achievementCompletedTaskModalText": "Hake Deine Aufgaben ab, um Belohnungen zu verdienen",
"achievementCompletedTaskText": "Hat die erste Aufgabe erledigt.",
"achievementCompletedTask": "Erledige eine Aufgabe",
"achievementCreatedTaskText": "Hat die erste Aufgabe erstellt."
} }

View File

@@ -485,5 +485,12 @@
"backgroundHolidayWreathText": "Adventskranz", "backgroundHolidayWreathText": "Adventskranz",
"backgroundHolidayMarketNotes": "Finde die perfekten Geschenke und Dekorationsartikel auf einem Weihnachtsmarkt.", "backgroundHolidayMarketNotes": "Finde die perfekten Geschenke und Dekorationsartikel auf einem Weihnachtsmarkt.",
"backgroundHolidayMarketText": "Weihnachtsmarkt", "backgroundHolidayMarketText": "Weihnachtsmarkt",
"backgrounds122019": "SET 67: Veröffentlicht im Dezember 2019" "backgrounds122019": "Set 67: Veröffentlicht im Dezember 2019",
"backgroundSnowglobeNotes": "Schüttele eine Schneekugel und nimm Deinen Platz im Mikrokosmos einer Winterlandschaft ein.",
"backgroundSnowglobeText": "Schneekugel",
"backgroundDesertWithSnowNotes": "Erlebe die rare und stille Schönheit einer Verschneiten Wüste.",
"backgroundDesertWithSnowText": "Verschneite Wüste",
"backgroundBirthdayPartyNotes": "Feiere den Geburtstag Deines Lieblingsmitmenschen in Habitica.",
"backgroundBirthdayPartyText": "Geburtstagsfeier",
"backgrounds012020": "Set 68: Veröffentlicht im Januar 2020"
} }

View File

@@ -352,5 +352,6 @@
"questEggRobotText": "Roboter-Haustier", "questEggRobotText": "Roboter-Haustier",
"hatchingPotionShadow": "Schatten", "hatchingPotionShadow": "Schatten",
"premiumPotionUnlimitedNotes": "Nicht auf Eier von Quest-Haustieren anwendbar.", "premiumPotionUnlimitedNotes": "Nicht auf Eier von Quest-Haustieren anwendbar.",
"hatchingPotionAmber": "Bernstein" "hatchingPotionAmber": "Bernstein",
"hatchingPotionAurora": "Polarlicht"
} }

View File

@@ -331,5 +331,6 @@
"getStarted": "Auf gehts!", "getStarted": "Auf gehts!",
"mobileApps": "Mobile Apps", "mobileApps": "Mobile Apps",
"learnMore": "Mehr erfahren", "learnMore": "Mehr erfahren",
"communityInstagram": "Instagram" "communityInstagram": "Instagram",
"minPasswordLength": "Das Passwort muss mindestens 8 Zeichen haben."
} }

View File

@@ -1978,10 +1978,52 @@
"headMystery201911Notes": "Jede Kristallspitze an diesem Hut verleiht Dir eine besondere Kraft: mystisches Hellsehen, arkane Weisheit und... hexerisches Tellerdrehen? Na dann... Gewährt keinen Attributbonus. Abonnentengegenstand, November 2019.", "headMystery201911Notes": "Jede Kristallspitze an diesem Hut verleiht Dir eine besondere Kraft: mystisches Hellsehen, arkane Weisheit und... hexerisches Tellerdrehen? Na dann... Gewährt keinen Attributbonus. Abonnentengegenstand, November 2019.",
"backMystery201912Notes": "Gleite leise über glänzende Schneefelder und schimmernde Berge mit diesen eisigen Flügeln. Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.", "backMystery201912Notes": "Gleite leise über glänzende Schneefelder und schimmernde Berge mit diesen eisigen Flügeln. Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.",
"backMystery201912Text": "Frostige Feenflügel", "backMystery201912Text": "Frostige Feenflügel",
"headMystery201912Notes": "Diese glitzernde Schneeflocke verleiht Dir Resistenz gegen die beißende Kälte, unabhängig davon wie hoch Du fliegst! Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.", "headMystery201912Notes": "Diese glitzernde Schneeflocke verleiht Dir Widerstandfähigkeit gegen die beißende Kälte, unabhängig davon wie hoch Du fliegst! Gewährt keinen Attributbonus. Abonnentengegenstand, Dezember 2019.",
"headMystery201912Text": "Frostige Feenkrone", "headMystery201912Text": "Polar-Pixiekrone",
"headArmoireEarflapHatText": "Uschanka", "headArmoireEarflapHatText": "Uschanka",
"armorArmoireDuffleCoatNotes": "Reise stilsicher durch eisige Gefilde mit diesem lauschigen Wollmantel. Erhöht Ausdauer und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 1 von 2).", "armorArmoireDuffleCoatNotes": "Reise stilsicher durch eisige Gefilde mit diesem lauschigen Wollmantel. Erhöht Ausdauer und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 1 von 2).",
"armorArmoireDuffleCoatText": "Düffelmantel", "armorArmoireDuffleCoatText": "Düffelmantel",
"headArmoireEarflapHatNotes": "Wenn Du etwas suchst, um Deinen Kopf wohlig warm zu halten, ist dieser Hut genau Dein Ding! Erhöht Intelligenz und Stärke um je <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 2 von 2)." "headArmoireEarflapHatNotes": "Wenn Du etwas suchst, um Deinen Kopf wohlig warm zu halten, ist dieser Hut genau Dein Ding! Erhöht Intelligenz und Stärke um je <%= attrs %>. Verzauberter Schrank: Düffelmantel-Set (Gegenstand 2 von 2).",
"shieldSpecialWinter2020HealerText": "Gigantische Zimtstange",
"shieldSpecialWinter2020WarriorText": "Zirkulärer Tannenzapfen",
"headSpecialWinter2020HealerText": "Sternanisabzeichen",
"headSpecialWinter2020MageText": "Glockenkrone",
"armorSpecialWinter2020HealerText": "Orangenschalenumhang",
"armorSpecialWinter2020WarriorNotes": "Oh mächtige Kiefer, oh überragende Tanne, leiht mir eure Kraft. Oder besser gesagt, eure Ausdauer! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"armorSpecialWinter2020WarriorText": "Borkenrüstung",
"armorSpecialWinter2020RogueText": "Puscheliger Parka",
"weaponSpecialWinter2020HealerNotes": "Schwinge es und sein Aroma wird Deine Freunde und Helfer herbeirufen, um mit dem Kochen und Backen zu beginnen! Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"weaponSpecialWinter2020HealerText": "Gewürznelkenzepter",
"weaponSpecialWinter2020MageNotes": "Mit etwas Übung, kannst Du diese akustische Magie in jeder gewünschten Frequenz generieren: ein meditatives Summen, ein festliches Läuten oder ein ROTE AUFGABE ÜBERFÄLLIG-ALARM. Erhöht Intelligenz um <%= int %> und Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"weaponSpecialWinter2020MageText": "Ringelige Schallwellen",
"weaponSpecialWinter2020WarriorNotes": "Zurück, Eichhörnchen! Ihr bekommt kein einziges Stück davon! ...Aber wenn Ihr alle bei einer Tasse Kakao abhängen wollt, geht das klar. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"weaponSpecialWinter2020WarriorText": "Zackiger Tannenzapfen",
"weaponSpecialWinter2020RogueNotes": "Dunkelheit ist des Schurken Element. Wer eigne sich da besser, den Weg zu leuchten in der dunkelsten Jahreszeit? Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"weaponSpecialWinter2020RogueText": "Laternenstab",
"shieldSpecialWinter2020HealerNotes": "Hast Du das Gefühl, Du seist zu gut für diese Welt, zu unverfälscht? Nur diese Schönheit unter den Gewürzen ist Deiner würdig. Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"headSpecialWinter2020HealerNotes": "Bitte nimm es ab, bevor Du versuchst damit einen Chai oder Kaffee aufzubrühen. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"headSpecialWinter2020MageNotes": "Oh! Süßer die Glocken nie klingen / als zu der Weihnachtszeit, / s ist, als ob Engelein singen, / \"Wende 'Flammenstoß' an\". Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"headSpecialWinter2020WarriorNotes": "Ein stechendes Gefühl auf Deinem Kopf ist ein kleiner Preis für saisonale Pracht. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2019-202 Winterausrüstung.",
"headSpecialWinter2020WarriorText": "Schneegekrönter Kopfschmuck",
"headSpecialWinter2020RogueNotes": "Ein Schurke geht in dieser Mütze die Straße entlang, die Leute wissen, so jemand fürchtet nichts. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"headSpecialWinter2020RogueText": "Flauschige Bommelmütze",
"armorSpecialWinter2020HealerNotes": "Ein opulenter Umhang für jene, mit festlichem Geschmack! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"armorSpecialWinter2020MageText": "Rundliche Robe",
"armorSpecialWinter2020RogueNotes": "Es besteht kein Zweifel, dass Du Stürmen mit der inneren Wärme Deines Elans und Deiner Hingabe trotzen kannst, aber es kann nicht schaden, sich dem Wetter entsprechend anzuziehen. Erhöht Wahrnehmung um <%= per %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"backMystery202001Notes": "Diese fluffigen Schwänze tragen himmlische Kräfte in sich, und ein ebenso hohes Niveau an Niedlichkeit! Gewährt keinen Attributbonus. Abonnentengegenstand, Januar 2020.",
"backMystery202001Text": "Fünf fabelhafte Schwänze",
"shieldSpecialWinter2020WarriorNotes": "Benutze ihn als Schild bis die Samen herunterfallen, und dann kannst Du ihn auf einen Kranz binden! Erhöht Ausdauer um <%= con %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"headMystery202001Notes": "Du bekommt ein so scharfes Gehör, dass Du hören kannst, wie die Sterne funkeln und der Mond sich dreht. Gewährt keinen Attributbonus. Abonnentengegenstand, Januar 2020.",
"headMystery202001Text": "Fabelhafte Fuchsohren",
"headSpecialNye2019Notes": "Du hast einen Frevelhaften Fetenhut erhalten! Trage ihn mit Stolz während Du das neue Jahr einläutest! Gewährt keinen Attributbonus.",
"headSpecialNye2019Text": "Frevelhafter Fetenhut",
"armorSpecialWinter2020MageNotes": "Läute das neue Jahr in dieser warmen, gemütlichen Robe ein, die Dich gegen übermässige Erschütterungen puffert. Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2019-2020 Winterausrüstung.",
"shieldArmoireBirthdayBannerNotes": "Feiere Deinen besonderen Tag, den besonderen Tag von jemandem, den Du liebst, oder trage es zum Geburtstag von Habitica am 31. Januar! Erhöht Stärke um <%= str %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 4 von 4).",
"shieldArmoireBirthdayBannerText": "Geburtstagsgösch",
"headArmoireFrostedHelmNotes": "Der perfekte Kopfschutz für jede Feier! Erhöht Intelligenz um <%= int %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 1 von 4).",
"headArmoireFrostedHelmText": "Glasierter Helm",
"armorArmoireLayerCakeArmorNotes": "Sie ist schützend und lecker! Erhöht Ausdauer um <%= con %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 2 von 4).",
"armorArmoireLayerCakeArmorText": "Schichttortenrüstung",
"weaponArmoireHappyBannerNotes": "Steht das \"H\" für Herzlich oder für Habitica? Entscheide selbst! Erhöht Wahrnehmung um <%= per %>. Verzauberter Schrank: Herzlichen Glückwunsch zum Geburtstag-Set (Gegenstand 3 von 4).",
"weaponArmoireHappyBannerText": "Herzliche Gösch"
} }

View File

@@ -3,7 +3,7 @@
"stringNotFound": "String '<%= string %>' nicht gefunden.", "stringNotFound": "String '<%= string %>' nicht gefunden.",
"titleIndex": "Habitica | Dein Leben, das Rollenspiel", "titleIndex": "Habitica | Dein Leben, das Rollenspiel",
"habitica": "Habitica", "habitica": "Habitica",
"habiticaLink": "<a href='http://habitica.fandom.com/wiki/Habitica' target='_blank'>Habitica</a>", "habiticaLink": "<a href='http://habitica.fandom.com/de/wiki/Habitica' target='_blank'>Habitica</a>",
"onward": "Vorwärts!", "onward": "Vorwärts!",
"done": "Erledigt", "done": "Erledigt",
"gotIt": "Verstanden!", "gotIt": "Verstanden!",
@@ -294,5 +294,7 @@
"options": "Optionen", "options": "Optionen",
"loadEarlierMessages": "Lade ältere Nachrichten", "loadEarlierMessages": "Lade ältere Nachrichten",
"demo": "Demo", "demo": "Demo",
"finish": "Abschliessen" "finish": "Abschliessen",
"congratulations": "Gratulation!",
"onboardingAchievs": "Einstiegserfolge"
} }

View File

@@ -147,7 +147,7 @@
"dateEndJanuary": "31. Januar", "dateEndJanuary": "31. Januar",
"dateEndFebruary": "28. Februar", "dateEndFebruary": "28. Februar",
"winterPromoGiftHeader": "Verschenke ein Abonnement und bekomme eins umsonst!", "winterPromoGiftHeader": "Verschenke ein Abonnement und bekomme eins umsonst!",
"winterPromoGiftDetails1": "Nur bis zum 15. Januar: wenn Du jemandem ein Abonnement schenkst, erhältst Du das gleiche Abonnement gratis für Dich!", "winterPromoGiftDetails1": "Nur bis zum 6. Januar: wenn Du jemandem ein Abonnement schenkst, erhältst Du das gleiche Abonnement gratis für Dich!",
"winterPromoGiftDetails2": "Bitte bedenke, dass das geschenkte Abonnement, falls Du oder Deine beschenkte Person bereits über ein sich wiederholendes Abonnement verfügen, erst dann startet, wenn das alte Abonnement gekündigt wird oder ausläuft. Herzlichen Dank für Deine Unterstützung! <3", "winterPromoGiftDetails2": "Bitte bedenke, dass das geschenkte Abonnement, falls Du oder Deine beschenkte Person bereits über ein sich wiederholendes Abonnement verfügen, erst dann startet, wenn das alte Abonnement gekündigt wird oder ausläuft. Herzlichen Dank für Deine Unterstützung! <3",
"discountBundle": "Paket", "discountBundle": "Paket",
"g1g1Announcement": "Die \"Schenke ein Abo, bekomm ein Abo Gratis\" Aktion läuft jetzt!", "g1g1Announcement": "Die \"Schenke ein Abo, bekomm ein Abo Gratis\" Aktion läuft jetzt!",
@@ -168,5 +168,10 @@
"fall2019RavenSet": "Rabe (Krieger)", "fall2019RavenSet": "Rabe (Krieger)",
"fall2019CyclopsSet": "Zyklop (Magier)", "fall2019CyclopsSet": "Zyklop (Magier)",
"fall2019OperaticSpecterSet": "Opernhaftes Gespenst (Schurke)", "fall2019OperaticSpecterSet": "Opernhaftes Gespenst (Schurke)",
"augustYYYY": "August <%= year %>" "augustYYYY": "August <%= year %>",
"winter2020LanternSet": "Laterne (Schurke)",
"winter2020WinterSpiceSet": "Wintergewürz (Heiler)",
"winter2020CarolOfTheMageSet": "Weihnachtslied des Magiers (Magier)",
"winter2020EvergreenSet": "Immergrün (Krieger)",
"decemberYYYY": "Dezember <%= year %>"
} }

Some files were not shown because too many files have changed in this diff Show More