diff --git a/public/js/app.js b/public/js/app.js index 9f30a29483..32e03deb57 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -75,6 +75,16 @@ window.habitrpg = angular.module('habitrpg', templateUrl: "partials/options.social.html" }) + .state('options.social.inbox', { + url: "/inbox", + templateUrl: "partials/options.social.inbox.html", + controller: ['$rootScope', function($rootScope){ + // clear "new messages" + //$rootScope.$on('userSynced',function(){ + $rootScope.User.user.ops.update && $rootScope.set({'inbox.newMessages':0}); + }] + }) + .state('options.social.tavern', { url: "/tavern", templateUrl: "partials/options.social.tavern.html", diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 3322087c58..a57198dafb 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -101,8 +101,8 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } ]) - .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', - function($scope, $rootScope, Members, Shared) { + .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', '$http', 'Notification', + function($scope, $rootScope, Members, Shared, $http, Notification) { $scope.timestamp = function(timestamp){ return moment(timestamp).format('MM/DD/YYYY'); } @@ -112,6 +112,13 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' member.petCount = Shared.countPets(null, member.items.pets); $scope.profile = member; }); + $scope.sendPrivateMessage = function(uuid, message){ + $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ + Notification.text('Message sent.'); + $rootScope.User.sync(); + $scope.$close(); + }); + } } ]) diff --git a/src/controllers/groups.js b/src/controllers/groups.js index 9f789f20dd..50272cdab2 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -19,7 +19,7 @@ var api = module.exports; ------------------------------------------------------------------------ */ -var partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; +var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; var nameFields = 'profile.name'; var challengeFields = '_id name'; var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }; @@ -44,15 +44,6 @@ var populateQuery = function(type, q){ return q; } - -api.getMember = function(req, res, next) { - User.findById(req.params.uid).select(partyFields).exec(function(err, user){ - if (err) return next(err); - if (!user) return res.json(400,{err:'User not found'}); - res.json(user); - }) -} - /** * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually * as /groups/party or /groups/tavern diff --git a/src/controllers/members.js b/src/controllers/members.js new file mode 100644 index 0000000000..96bd66b421 --- /dev/null +++ b/src/controllers/members.js @@ -0,0 +1,57 @@ +var User = require('mongoose').model('User'); +var groups = require('../models/group'); +var partyFields = require('./groups').partyFields +var api = module.exports; +var async = require('async'); +var _ = require('lodash'); +var shared = require('habitrpg-shared'); + +api.getMember = function(req, res, next) { + User.findById(req.params.uuid).select(partyFields).exec(function(err, user){ + if (err) return next(err); + if (!user) return res.json(400,{err:'User not found'}); + res.json(user); + }) +} + +api.sendPrivateMessage = function(req,res,next){ + async.waterfall([ + function(cb){ + User.findById(req.params.uuid, cb); + }, + function(member, cb){ + if (!member) return cb({code:404, err: 'User not found'}); + if (~member.inbox.blocks.indexOf(res.locals.user._id) || member.inbox.optOut) { + return cb({code:401, err: "Can't send message to this user."}); + } + + var message = groups.chatDefaults(req.body.message, res.locals.user); + shared.refPush(member.inbox.messages, message); + member.inbox.newMessages++; + member._v++; + member.markModified('inbox.messages'); + + var message = groups.chatDefaults(req.body.message, member); + shared.refPush(res.locals.user.inbox.messages, _.defaults({sent:true},message)); + res.locals.user.markModified('inbox.messages'); + + member.save(cb); + }, + function(a,b,cb){ + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code,{err:err.err}) : err; + res.send(200); + }) + +} + +api.block = function(req,res,next){ + var b = res.locals.user.inbox; + if (~b.blocks.indexOf(req.params.id)){ + b.blocks.push(req.params.id) + res.locals.user.save(); + } + res.send(200); +} diff --git a/src/controllers/user.js b/src/controllers/user.js index 5b732dabdc..a1c71908a0 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -216,7 +216,7 @@ api.getUser = function(req, res, next) { * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations */ acceptablePUTPaths = _.reduce(require('./../models/user').schema.paths, function(m,v,leaf){ - var found= _.find('achievements filters flags invitations lastCron party preferences profile stats'.split(' '), function(root){ + var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){ return leaf.indexOf(root) == 0; }); if (found) m[leaf]=true; diff --git a/src/models/group.js b/src/models/group.js index 2f8f6674b9..fd2518b8ed 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -93,7 +93,7 @@ GroupSchema.methods.toJSON = function(){ return doc; } -var chatDefaults = function(message,user){ +var chatDefaults = module.exports.chatDefaults = function(message,user){ var message = { id: shared.uuid(), text: message, diff --git a/src/models/user.js b/src/models/user.js index 207dc4c8a8..2be3e9fd95 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -326,6 +326,13 @@ var UserSchema = new Schema({ challenges: [{type: 'String', ref:'Challenge'}], + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + habits: {type:[TaskSchemas.HabitSchema]}, dailys: {type:[TaskSchemas.DailySchema]}, todos: {type:[TaskSchemas.TodoSchema]}, diff --git a/src/routes/apiv2.coffee b/src/routes/apiv2.coffee index 357ebcfb00..fe7db360cc 100644 --- a/src/routes/apiv2.coffee +++ b/src/routes/apiv2.coffee @@ -10,6 +10,7 @@ $ mocha test/user.mocha.coffee user = require("../controllers/user") groups = require("../controllers/groups") +members = require("../controllers/members") auth = require("../controllers/auth") hall = require("../controllers/hall") challenges = require("../controllers/challenges") @@ -579,9 +580,28 @@ module.exports = (swagger, v2) -> # --------------------------------- # Members # --------------------------------- - "/members/{uid}": + "/members/{uuid}": spec:{} - action: groups.getMember + action: members.getMember + "/members/{uuid}/message": + spec: + method: 'POST' + description: 'Send a private message to a member' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + body '', '{message: "The private message to send"}', 'object' + ] + middleware: [auth.auth] + action: members.sendPrivateMessage + "/members/{uuid}/block": + spec: + method: 'POST' + description: 'Block a member from sending private messages' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + ] + middleware: [auth.auth] + action: members.block # --------------------------------- # Hall of Heroes / Patrons diff --git a/views/options/social/chat-message.jade b/views/options/social/chat-message.jade index d7507908a5..7264525901 100644 --- a/views/options/social/chat-message.jade +++ b/views/options/social/chat-message.jade @@ -1,19 +1,26 @@ -li.chat-message(ng-repeat='message in group.chat track by message.id', ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}') - .scrollable-message - span(ng-if='::message.user') - a.label.label-default.chat-message.hidden-label - span {{::message.user}}  - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') - // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text - markdown(ng-model='::message.text') - | - - span.muted.time(from-now='::message.timestamp') - span - a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} - a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group,message)') +1 - |   - a(ng-if=':: user.contributor.admin || message.uuid == user.id', ng-click='deleteChatMessage(group, message)') - span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) - a.label.label-default.chat-message(ng-if=':: message.user', class='float-label', ng-class='userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') - span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}}  - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') +mixin chatMessages(inbox) + ul.list-unstyled.tavern-chat + - var ngRepeat = inbox ? 'message in user.inbox.messages | toArray:true | orderBy:"sort":true' : 'message in group.chat track by message.id' + li.chat-message(ng-repeat=ngRepeat, ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}', style='{{::message.sent ? "opacity:0.5" : ""}}') + .scrollable-message + span(ng-if='::message.user') + a.label.label-default.chat-message.hidden-label + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + span {{::message.user}}  + span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') + // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text + markdown(ng-model='::message.text') + | - + span.muted.time(from-now='::message.timestamp') + unless type='inbox' + span + a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} + a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group, message)') +1 + |   + a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}', ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + span.float-label + a.label.label-default.chat-message(ng-if=':: message.user', ng-class='::userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}}  + span(ng-class='::userAdminGlyphiconStyleFromLevel(message.contributor.level)') diff --git a/views/options/social/group.jade b/views/options/social/group.jade index 00ed6a49f5..ec36aea8b4 100644 --- a/views/options/social/group.jade +++ b/views/options/social/group.jade @@ -114,5 +114,4 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3=env.t('chat') include ./chat-box - ul.list-unstyled.tavern-chat - include ./chat-message + +chatMessages() diff --git a/views/options/social/index.jade b/views/options/social/index.jade index b632b1c6d9..2582e62c11 100644 --- a/views/options/social/index.jade +++ b/views/options/social/index.jade @@ -1,9 +1,20 @@ // FIXME note, due to https://github.com/angular-ui/bootstrap/issues/783 we can't use nested angular-bootstrap tabs // Subscribe to that ticket & change this when they fix -include ./challenges.jade -include ./hall.jade -include ./boss.jade +include ./challenges +include ./hall +include ./boss +include ./chat-message + +script(type='text/ng-template', id='partials/options.social.inbox.html') + .container-fluid + .row + .col-md-12 + +chatMessages('inbox') + .checkbox + label + input(type='checkbox', ng-model='user.inbox.optOut', ng-change='set({"inbox.optOut": user.inbox.optOut?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover="Don't like private messages? Click to completely opt out") Opt Out script(type='text/ng-template', id='partials/options.social.tavern.html') include ./tavern @@ -78,6 +89,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.html') script(type='text/ng-template', id='partials/options.social.html') ul.options-menu + li(ng-class="{ active: $state.includes('options.social.inbox') }") + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li(ng-class="{ active: $state.includes('options.social.tavern') }") a(ui-sref='options.social.tavern') =env.t('tavern') diff --git a/views/options/social/tavern.jade b/views/options/social/tavern.jade index 72826de4fc..753dd02c45 100644 --- a/views/options/social/tavern.jade +++ b/views/options/social/tavern.jade @@ -160,5 +160,4 @@ |   =env.t('communityGuidelinesRead2') - ul.list-unstyled.tavern-chat - include ./chat-message + +chatMessages() diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 8c1994a1b4..df5386be07 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -24,6 +24,10 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.profile.profile')=env.t('profile') ul.toolbar-submenu + li + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li a(ui-sref='options.social.tavern')=env.t('tavern') li @@ -88,10 +92,15 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li.toolbar-button-dropdown a(ui-sref='options.social.tavern') span=env.t('social') + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} a(ng-click='expandMenu("social")', ng-class='{active: _expandedMenu == "social"}') span ☰ div(ng-if='_expandedMenu == "social"') ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li a(ui-sref='options.social.tavern')=env.t('tavern') li diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index eb7e843bb9..40c190c45f 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -1,28 +1,32 @@ -script(type='text/ng-template', id='modals/member.html') - .modal-header(bindonce='profile') +script(type='text/ng-template', id='modals/member.html') + .modal-header h4 - span {{profile.profile.name}} - span(ng-if='profile.contributor.level') - {{contribText(profile.contributor, profile.backer)}} - .modal-body(bindonce='profile') + span {{::profile.profile.name}} + span(ng-if='profile.contributor.level') - {{::contribText(profile.contributor, profile.backer)}} + ul.pull-right.list-inline(ng-if='::user') + li.glyphicon.glyphicon-plus(ng-show='user.inbox.blocks | contains:profile._id', tooltip='Un-block', ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='bottom', style='cursor:pointer') + li.glyphicon.glyphicon-remove(ng-hide='user.inbox.blocks | contains:profile._id', tooltip='Block', ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='bottom', style='cursor:pointer') + li.glyphicon.glyphicon-envelope(tooltip='Send private message', ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='bottom', style='cursor:pointer') + .modal-body .container-fluid .row .col-md-6 - img(ng-show='profile.profile.imageUrl', ng-src='{{profile.profile.imageUrl}}') - markdown(ng-show='profile.profile.blurb', ng-model='profile.profile.blurb') - ul.muted.list-unstyled(ng-if='profile.auth.timestamps') + img(ng-show='::profile.profile.imageUrl', ng-src='{{::profile.profile.imageUrl}}') + markdown(ng-show='::profile.profile.blurb', ng-model='::profile.profile.blurb') + ul.muted.list-unstyled(ng-if='::profile.auth.timestamps') li {{profile._id}} - li(ng-show='profile.auth.timestamps.created') + li(ng-show='::profile.auth.timestamps.created') |  =env.t('memberSince') |  - | {{timestamp(profile.auth.timestamps.created)}} - - li(ng-show='profile.auth.timestamps.loggedin') + | {{::timestamp(profile.auth.timestamps.created)}} - + li(ng-show='::profile.auth.timestamps.loggedin') |  =env.t('lastLoggedIn') |  - | {{timestamp(profile.auth.timestamps.loggedin)}} - + | {{::timestamp(profile.auth.timestamps.loggedin)}} - h3=env.t('stats') - .label.label-info {{ {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[profile.stats.class] }} + .label.label-info {{:: {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[profile.stats.class] }} include ../profiles/stats .col-md-6 +herobox() @@ -30,3 +34,12 @@ script(type='text/ng-template', id='modals/member.html') include ../profiles/achievements .modal-footer button.btn.btn-default(ng-click='$close()')=env.t('ok') + +script(type='text/ng-template', id='modals/private-message.html') + .modal-header + h4 Private message to {{profile.profile.name}} + .modal-body + textarea.form-control(type='text',ng-model='_message') + .modal-footer + button.btn.btn-primary(ng-click='sendPrivateMessage(profile._id, _message)') Send + button.btn.btn-default(ng-click='$close()')=env.t('cancel') \ No newline at end of file