mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-08-03 12:40:00 +00:00
feat(email-notifications): implement email notifications for events
This commit is contained in:
parent
403554ae3a
commit
d211bc7f86
11 changed files with 217 additions and 44 deletions
|
|
@ -213,6 +213,10 @@ window.habitrpg = angular.module('habitrpg',
|
|||
url: "/subscription",
|
||||
templateUrl: "partials/options.settings.subscription.html"
|
||||
})
|
||||
.state('options.settings.notifications', {
|
||||
url: "/notifications",
|
||||
templateUrl: "partials/options.settings.notifications.html"
|
||||
})
|
||||
|
||||
var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID));
|
||||
if (settings && settings.auth) {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ api.registerUser = function(req, res, next) {
|
|||
}
|
||||
|
||||
user.save(cb);
|
||||
if(isProd) utils.txnEmail({name:username, email:email}, 'welcome');
|
||||
utils.txnEmail(user, 'welcome');
|
||||
ga.event('register', 'Local').send()
|
||||
}
|
||||
], function(err, saved) {
|
||||
|
|
@ -173,9 +173,7 @@ api.loginSocial = function(req, res, next) {
|
|||
user = new User(user);
|
||||
user.save(cb);
|
||||
|
||||
if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) {
|
||||
utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome');
|
||||
}
|
||||
utils.txnEmail(user, 'welcome');
|
||||
ga.event('register', network).send();
|
||||
}]
|
||||
}, function(err, results){
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ var Group = require('./../models/group').model;
|
|||
var Challenge = require('./../models/challenge').model;
|
||||
var logging = require('./../logging');
|
||||
var csv = require('express-csv');
|
||||
var utils = require('../utils');
|
||||
var api = module.exports;
|
||||
|
||||
|
||||
|
|
@ -335,6 +336,11 @@ api.selectWinner = function(req, res, next) {
|
|||
winner.save(cb);
|
||||
},
|
||||
function(saved, num, cb) {
|
||||
if(saved.preferences.emailNotifications.wonChallenge !== false){
|
||||
utils.txnEmail(saved, 'won-challenge', [
|
||||
{name: 'CHALLENGE_NAME', content: chal.name}
|
||||
]);
|
||||
}
|
||||
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
|
||||
}
|
||||
], function(err){
|
||||
|
|
|
|||
|
|
@ -301,13 +301,11 @@ api.flagChatMessage = function(req, res, next){
|
|||
group.markModified('chat');
|
||||
group.save(function(err,_saved){
|
||||
if(err) return next(err);
|
||||
if (isProd){
|
||||
|
||||
var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL'));
|
||||
|
||||
if(Array.isArray(addressesToSendTo)){
|
||||
addressesToSendTo = addressesToSendTo.map(function(email){
|
||||
return {email: email}
|
||||
return {email: email, canSend: true}
|
||||
});
|
||||
}else{
|
||||
addressesToSendTo = {email: addressesToSendTo}
|
||||
|
|
@ -332,7 +330,7 @@ api.flagChatMessage = function(req, res, next){
|
|||
{name: "GROUP_ID", content: group._id},
|
||||
{name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')},
|
||||
]);
|
||||
}
|
||||
|
||||
return res.send(204);
|
||||
});
|
||||
});
|
||||
|
|
@ -556,6 +554,26 @@ api.invite = function(req, res, next) {
|
|||
], function(err, results){
|
||||
if (err) return next(err);
|
||||
|
||||
if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){
|
||||
var emailVars = [
|
||||
{name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name}
|
||||
];
|
||||
|
||||
if(group.type == 'guild'){
|
||||
emailVars.push(
|
||||
{name: 'GUILD_NAME', content: group.name},
|
||||
{name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/' + group._id}
|
||||
);
|
||||
}else{
|
||||
emailVars.push(
|
||||
{name: 'PARTY_NAME', content: group.name},
|
||||
{name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
|
||||
)
|
||||
}
|
||||
|
||||
utils.txnEmail(invite, ('invited-' + group.type == 'guild' ? 'guild' : 'party'), emailVars);
|
||||
}
|
||||
|
||||
// Have to return whole group and its members for angular to show the invited user
|
||||
res.json(results[2]);
|
||||
group = uuid = null;
|
||||
|
|
@ -629,7 +647,7 @@ questStart = function(req, res, next) {
|
|||
var group = res.locals.group;
|
||||
var force = req.query.force;
|
||||
|
||||
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
|
||||
// if (group.quest.active) return res.json(400,{err:'Quest already began.'});
|
||||
// temporarily send error email, until we know more about this issue (then remove below, uncomment above).
|
||||
if (group.quest.active) return next('Quest already began.');
|
||||
|
||||
|
|
@ -720,8 +738,9 @@ api.questAccept = function(req, res, next) {
|
|||
if (m == user._id) {
|
||||
group.quest.members[m] = true;
|
||||
group.quest.leader = user._id;
|
||||
} else
|
||||
} else {
|
||||
group.quest.members[m] = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Party member accepting the invitation
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ var api = module.exports;
|
|||
var async = require('async');
|
||||
var _ = require('lodash');
|
||||
var shared = require('habitrpg-shared');
|
||||
var utils = require('../utils');
|
||||
var nconf = require('nconf');
|
||||
|
||||
var fetchMember = function(uuid, restrict){
|
||||
return function(cb){
|
||||
|
|
@ -48,9 +50,11 @@ api.sendMessage = function(user, member, data){
|
|||
}
|
||||
|
||||
api.sendPrivateMessage = function(req, res, next){
|
||||
var fetchedMember;
|
||||
async.waterfall([
|
||||
fetchMember(req.params.uuid),
|
||||
function(member, cb) {
|
||||
fetchedMember = member;
|
||||
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|
||||
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|
||||
|| member.inbox.optOut) { // or if they've opted out of messaging
|
||||
|
|
@ -64,6 +68,14 @@ api.sendPrivateMessage = function(req, res, next){
|
|||
}
|
||||
], function(err){
|
||||
if (err) return sendErr(err, res, next);
|
||||
|
||||
if(fetchedMember.preferences.emailNotifications.newPM !== false){
|
||||
utils.txnEmail(fetchedMember, 'new-pm', [
|
||||
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
|
||||
{name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'}
|
||||
]);
|
||||
}
|
||||
|
||||
res.send(200);
|
||||
})
|
||||
}
|
||||
|
|
@ -84,6 +96,12 @@ api.sendGift = function(req, res, next){
|
|||
member.balance += amt;
|
||||
user.balance -= amt;
|
||||
api.sendMessage(user, member, req.body);
|
||||
if(member.preferences.emailNotifications.giftedGems !== false){
|
||||
utils.txnEmail(member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name},
|
||||
{name: 'X_GEMS_GIFTED', content: amt}
|
||||
]);
|
||||
}
|
||||
return async.parallel([
|
||||
function (cb2) { member.save(cb2) },
|
||||
function (cb2) { user.save(cb2) }
|
||||
|
|
|
|||
|
|
@ -73,7 +73,15 @@ exports.createSubscription = function(data, cb) {
|
|||
utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send();
|
||||
}
|
||||
data.user.purchased.txnCount++;
|
||||
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if (data.gift){
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){
|
||||
utils.txnEmail(member, 'gifted-subscription', [
|
||||
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
|
||||
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
|
||||
]);
|
||||
}
|
||||
}
|
||||
async.parallel([
|
||||
function(cb2){data.user.save(cb2)},
|
||||
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
|
||||
|
|
@ -96,7 +104,7 @@ exports.cancelSubscription = function(data, cb) {
|
|||
p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
|
||||
|
||||
data.user.save(cb);
|
||||
if(isProduction) utils.txnEmail(data.user, 'cancel-subscription');
|
||||
utils.txnEmail(data.user, 'cancel-subscription');
|
||||
utils.ga.event('unsubscribe', data.paymentMethod).send();
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +118,15 @@ exports.buyGems = function(data, cb) {
|
|||
//TODO ga.transaction to reflect whether this is gift or self-purchase
|
||||
utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send();
|
||||
}
|
||||
if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if (data.gift){
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if(data.gift.member.preferences.emailNotifications.giftedGems !== false){
|
||||
utils.txnEmail(member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
|
||||
{name: 'X_GEMS_GIFTED', content: amt}
|
||||
]);
|
||||
}
|
||||
}
|
||||
async.parallel([
|
||||
function(cb2){data.user.save(cb2)},
|
||||
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
|
||||
|
|
|
|||
|
|
@ -445,6 +445,9 @@ api.inviteFriends = function(req, res, next) {
|
|||
{name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name},
|
||||
{name: 'INVITEE', content: invite.name}
|
||||
];
|
||||
|
||||
invite.canSend = true;
|
||||
|
||||
// TODO implement "users can only be invited once"
|
||||
utils.txnEmail(invite, 'invite-friend', variables);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,20 @@ var UserSchema = new Schema({
|
|||
advancedCollapsed: {type: Boolean, 'default': false},
|
||||
toolbarCollapsed: {type:Boolean, 'default':false},
|
||||
background: String,
|
||||
webhooks: {type: Schema.Types.Mixed, 'default': {}}
|
||||
webhooks: {type: Schema.Types.Mixed, 'default': {}},
|
||||
// For this fields make sure to use strict comparison when searching for falsey values (=== false)
|
||||
// As users who didn't login after these were introduced may have them undefined/null
|
||||
emailNotifications: {
|
||||
unsubscribeFromAll: {type: Boolean, 'default': false},
|
||||
newPM: {type: Boolean, 'default': true},
|
||||
wonChallenge: {type: Boolean, 'default': true},
|
||||
giftedGems: {type: Boolean, 'default': true},
|
||||
giftedSubscription: {type: Boolean, 'default': true},
|
||||
invitedParty: {type: Boolean, 'default': true},
|
||||
invitedGuild: {type: Boolean, 'default': true},
|
||||
//remindersToLogin: {type: Boolean, 'default': true},
|
||||
importantAnnouncements: {type: Boolean, 'default': true}
|
||||
}
|
||||
},
|
||||
profile: {
|
||||
blurb: String,
|
||||
|
|
|
|||
93
src/utils.js
93
src/utils.js
|
|
@ -4,6 +4,9 @@ var crypto = require('crypto');
|
|||
var path = require("path");
|
||||
var request = require('request');
|
||||
|
||||
// Set when utils.setupConfig is run
|
||||
var isProd, baseUrl;
|
||||
|
||||
module.exports.ga = undefined; // set Google Analytics on nconf init
|
||||
|
||||
module.exports.sendEmail = function(mailData) {
|
||||
|
|
@ -22,48 +25,75 @@ module.exports.sendEmail = function(mailData) {
|
|||
});
|
||||
}
|
||||
|
||||
function getMailingInfo(user) {
|
||||
var email, name;
|
||||
if(user.auth.local && user.auth.local.email){
|
||||
email = user.auth.local.email;
|
||||
name = user.profile.name || user.auth.local.username;
|
||||
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
|
||||
email = user.auth.facebook.emails[0].value;
|
||||
name = user.auth.facebook.displayName || user.auth.facebook.username;
|
||||
function getUserInfo(user, fields) {
|
||||
var info = {};
|
||||
|
||||
if(fields.indexOf('name') != -1){
|
||||
if(user.auth.local){
|
||||
info.name = user.profile.name || user.auth.local.username;
|
||||
}else if(user.auth.facebook){
|
||||
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
|
||||
}
|
||||
}
|
||||
return {email: email, name: name};
|
||||
|
||||
if(fields.indexOf('email') != -1){
|
||||
if(user.auth.local){
|
||||
info.email = user.auth.local.email;
|
||||
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
|
||||
info.email = user.auth.facebook.emails[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
if(fields.indexOf('canSend') != -1){
|
||||
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
module.exports.getUserInfo = getUserInfo;
|
||||
|
||||
module.exports.txnEmail = function(mailingInfoArray, emailType, variables){
|
||||
var variables = [{name: 'BASE_URL', content: nconf.get('BASE_URL')}].concat(variables || []);
|
||||
var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
|
||||
var variables = [
|
||||
{name: 'BASE_URL', content: baseUrl},
|
||||
{name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'}
|
||||
].concat(variables || []);
|
||||
|
||||
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
|
||||
mailingInfoArray = mailingInfoArray.map(function(mailingInfo){
|
||||
return mailingInfo._id ? getMailingInfo(mailingInfo) : mailingInfo;
|
||||
return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo;
|
||||
}).filter(function(mailingInfo){
|
||||
return mailingInfo.email ? true : false;
|
||||
return (mailingInfo.email && mailingInfo.canSend);
|
||||
});
|
||||
|
||||
request({
|
||||
url: nconf.get('EMAIL_SERVER:url') + '/job',
|
||||
method: 'POST',
|
||||
auth: {
|
||||
user: nconf.get('EMAIL_SERVER:authUser'),
|
||||
pass: nconf.get('EMAIL_SERVER:authPassword')
|
||||
},
|
||||
json: {
|
||||
type: 'email',
|
||||
data: {
|
||||
emailType: emailType,
|
||||
to: mailingInfoArray,
|
||||
variables: variables
|
||||
// When only one recipient send his info as variables
|
||||
if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){
|
||||
variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name});
|
||||
}
|
||||
|
||||
if(isProd && mailingInfoArray.length > 0){
|
||||
request({
|
||||
url: nconf.get('EMAIL_SERVER:url') + '/job',
|
||||
method: 'POST',
|
||||
auth: {
|
||||
user: nconf.get('EMAIL_SERVER:authUser'),
|
||||
pass: nconf.get('EMAIL_SERVER:authPassword')
|
||||
},
|
||||
options: {
|
||||
attemps: 5,
|
||||
backoff: {delay: 10*60*1000, type: 'fixed'}
|
||||
json: {
|
||||
type: 'email',
|
||||
data: {
|
||||
emailType: emailType,
|
||||
to: mailingInfoArray,
|
||||
variables: variables
|
||||
},
|
||||
options: {
|
||||
attemps: 5,
|
||||
backoff: {delay: 10*60*1000, type: 'fixed'}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/
|
||||
|
|
@ -93,6 +123,9 @@ module.exports.setupConfig = function(){
|
|||
if (nconf.get('NODE_ENV') === 'production')
|
||||
require('newrelic');
|
||||
|
||||
isProd = nconf.get('NODE_ENV') === 'production';
|
||||
baseUrl = nconf.get('BASE_URL');
|
||||
|
||||
module.exports.ga = require('universal-analytics')(nconf.get('GA_ID'));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ script(id='partials/options.settings.html', type="text/ng-template")
|
|||
=env.t('coupon')
|
||||
li(ng-class="{ active: $state.includes('options.settings.subscription') }")
|
||||
a(ui-sref='options.settings.subscription')=env.t('subscription')
|
||||
li(ng-class="{ active: $state.includes('options.settings.notifications') }")
|
||||
a(ui-sref='options.settings.notifications')=env.t('notifications')
|
||||
|
||||
.tab-content
|
||||
.tab-pane.active
|
||||
|
|
@ -84,6 +86,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html')
|
|||
|
|
||||
=env.t('subWarning3')
|
||||
|
||||
.personal-options.col-md-6
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
span Registration
|
||||
|
|
@ -242,6 +245,64 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template')
|
|||
input.focusable(type='checkbox', checked)
|
||||
label
|
||||
|
||||
script(id='partials/options.settings.notifications.html', type="text/ng-template")
|
||||
.container-fluid
|
||||
.row
|
||||
.personal-options.col-md-6
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
=env.t('emailNotifications')
|
||||
.panel-body
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})')
|
||||
span=env.t('newPM')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})')
|
||||
span=env.t('wonChallenge')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})')
|
||||
span=env.t('giftedGems')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})')
|
||||
span=env.t('giftedSubscription')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})')
|
||||
span=env.t('invitedParty')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})')
|
||||
span=env.t('invitedGuild')
|
||||
|
||||
//.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})')
|
||||
span=env.t('remindersToLogin')
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})')
|
||||
span=env.t('importantAnnouncements')
|
||||
|
||||
hr
|
||||
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})')
|
||||
span=env.t('unsubscribeAllEmails')
|
||||
|
||||
small=env.t('unsubscribeAllEmailsText')
|
||||
|
||||
script(id='partials/options.settings.subscription.html',type='text/ng-template')
|
||||
//-h2=env.t('individualSub')
|
||||
.container-fluid(ng-init='_subscription={key:"basic_earned"}')
|
||||
|
|
|
|||
|
|
@ -242,6 +242,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
|
|||
a(ui-sref='options.settings.coupon') Coupon
|
||||
li
|
||||
a(ui-sref='options.settings.subscription')=env.t('subscription')
|
||||
li
|
||||
a(ui-sref='options.settings.notifications')=env.t('notifications')
|
||||
ul.toolbar-submenu(ng-click='expandMenu(null)')
|
||||
li
|
||||
a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ')
|
||||
|
|
|
|||
Loading…
Reference in a new issue