diff --git a/common/img/gryphon_192-20.png b/common/img/gryphon_192-20.png new file mode 100644 index 0000000000..045d89d37c Binary files /dev/null and b/common/img/gryphon_192-20.png differ diff --git a/common/locales/en/generic.json b/common/locales/en/generic.json index 52b9d72a41..325ddb702c 100644 --- a/common/locales/en/generic.json +++ b/common/locales/en/generic.json @@ -182,5 +182,9 @@ "raisePetShare": "I raised a pet into a mount by completing my real-life tasks!", "wonChallengeShare": "I won a challenge in Habitica!", "achievementShare": "I earned a new achievement in Habitica!", - "orderBy": "Order By <%= item %>" + "orderBy": "Order By <%= item %>", + + "you": "you", + "online": "online", + "onlineCount": "<%= count %> online" } diff --git a/common/locales/en/groups.json b/common/locales/en/groups.json index 2762732bdf..f04375eb39 100644 --- a/common/locales/en/groups.json +++ b/common/locales/en/groups.json @@ -45,6 +45,7 @@ "chat": "Chat", "sendChat": "Send Chat", "toolTipMsg": "Fetch Recent Messages", + "syncPartyAndChat": "Sync Party and Chat", "guildBankPop1": "Guild Bank", "guildBankPop2": "Gems which your guild leader can use for challenge prizes.", "guildGems": "Guild Gems", @@ -194,5 +195,7 @@ "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", "onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!", - "onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned" + "onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned", + "newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!", + "newChatMessageTitle": "New message in <%= groupName %>" } diff --git a/website/client/js/config.js b/website/client/js/config.js index 1c145d5475..6ac3097f42 100644 --- a/website/client/js/config.js +++ b/website/client/js/config.js @@ -13,13 +13,14 @@ angular.module('habitrpg') var isUserAvailable = $rootScope.appLoaded === true; var hasUserV = response.data && response.data.userV; var isNotSync = response.config.url.indexOf('/api/v3/user') !== 0 || response.config.method !== 'GET'; + var isNotMarkChatSeen = response.config.url.indexOf('/chat/seen') === -1; // exclude chat seen requests because with real time chat they would be too many if (isApiCall && isUserAvailable && hasUserV) { var oldUserV = $rootScope.User.user._v; $rootScope.User.user._v = response.data.userV; // Something has changed on the user object that was not tracked here, sync the user - if (isNotSync && ($rootScope.User.user._v - oldUserV) > 1) { + if (isNotMarkChatSeen && isNotSync && ($rootScope.User.user._v - oldUserV) > 1) { $rootScope.User.sync(); } } diff --git a/website/client/js/controllers/chatCtrl.js b/website/client/js/controllers/chatCtrl.js index 976d5d4142..649a06b9a7 100644 --- a/website/client/js/controllers/chatCtrl.js +++ b/website/client/js/controllers/chatCtrl.js @@ -2,9 +2,6 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'ApiUrl', 'Notification', 'Members', '$rootScope', 'Analytics', function($scope, Groups, Chat, User, $http, ApiUrl, Notification, Members, $rootScope, Analytics){ - if ($scope.group) { - Chat.markChatSeen($scope.group.id); - } $scope.message = {content:''}; $scope._sending = false; diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index 8ecd9bcaf9..cb06e4d601 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -1,7 +1,7 @@ 'use strict'; -habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social','Pusher','Achievement', - function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social, Pusher, Achievement) { +habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', 'Achievement', + function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social, Achievement) { var PARTY_LOADING_MESSAGES = 4; @@ -19,8 +19,19 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' $scope.partyLoadingMessage = window.env.t('partyLoading' + partyMessageNumber); function handlePartyResponse (group) { - $rootScope.party = $scope.group = group; + // Assign and not replace so that all the references get the modifications + _.assign($rootScope.party, group); + $scope.group = $rootScope.party; + $scope.group.loadingParty = false; checkForNotifications(); + if ($state.is('options.social.party')) { + if ('Notification' in window && window.Notification.permission === 'default') { + setTimeout(function () { + window.Notification.requestPermission(); + }, 100); + } + Chat.markChatSeen($scope.group._id); + } } function handlePartyError (response) { @@ -28,7 +39,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } if ($state.is('options.social.party') && $rootScope.party && $rootScope.party.id) { - Groups.party(true).then(handlePartyResponse, handlePartyError); + Groups.party().then(handlePartyResponse, handlePartyError); } else { Groups.Group.syncParty().then(handlePartyResponse, handlePartyError); } @@ -49,10 +60,6 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' } } - if ($scope.group && $scope.group._id) { - Chat.markChatSeen($scope.group._id); - } - $scope.create = function(group) { group.loadingParty = true; diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 394dcbcf7b..c304fbba98 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics','TAVERN_ID', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrl', 'Payments','$sce','$window','Analytics','TAVERN_ID', 'Pusher', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID, Pusher) { var user = User.user; var IGNORE_SCROLL_PAGES = { 'options.social.challenges.detail': true, @@ -55,6 +55,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.toJson = angular.toJson; $rootScope.Payments = Payments; $rootScope.userNotifications = []; + $rootScope.party = {}; // to be extended later with real data // Angular UI Router $rootScope.$state = $state; diff --git a/website/client/js/services/chatServices.js b/website/client/js/services/chatServices.js index b6eab706a0..993b1a3f2b 100644 --- a/website/client/js/services/chatServices.js +++ b/website/client/js/services/chatServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Chat', ['$http', 'ApiUrl', 'User', 'Pusher', - function($http, ApiUrl, User, Pusher) { +.factory('Chat', ['$http', 'ApiUrl', 'User', '$rootScope', + function($http, ApiUrl, User, $rootScope) { var apiV3Prefix = '/api/v3'; function getChat (groupId) { @@ -24,7 +24,7 @@ angular.module('habitrpg') url: url, data: { message: message, - pusherSocketId: Pusher.socketId, // to make sure the send doesn't get notified of it's own message + pusherSocketId: $rootScope.pusherSocketId, // to make sure the send doesn't get notified of it's own message } }); } diff --git a/website/client/js/services/notificationServices.js b/website/client/js/services/notificationServices.js index b6b854cde1..3730910cef 100644 --- a/website/client/js/services/notificationServices.js +++ b/website/client/js/services/notificationServices.js @@ -93,9 +93,9 @@ angular.module("habitrpg").factory("Notification", _notify(window.env.t('streakName') + ': ' + val, 'streak', 'glyphicon glyphicon-repeat'); } - function text(val){ + function text(val, onClick){ if (val) { - _notify(val, 'info'); + _notify(val, 'info', null, null, onClick); } } @@ -114,7 +114,7 @@ angular.module("habitrpg").factory("Notification", // Used to stack notifications, must be outside of _notify var stack_topright = {"dir1": "down", "dir2": "left", "spacing1": 15, "spacing2": 15, "firstpos1": 60}; - function _notify(html, type, icon, canHide) { + function _notify(html, type, icon, canHide, onClick) { var notice = $.pnotify({ type: type || 'warning', //('info', 'text', 'warning', 'success', 'gp', 'xp', 'hp', 'lvl', 'death', 'mp', 'crit') text: html, @@ -126,7 +126,13 @@ angular.module("habitrpg").factory("Notification", width: "250px", stack: stack_topright, icon: icon || false - }).click(function() { notice.pnotify_remove() }); + }).click(function() { + notice.pnotify_remove(); + + if (onClick) { + onClick(); + } + }); } return { diff --git a/website/client/js/services/pusherService.js b/website/client/js/services/pusherService.js index cc5b1f3087..fd2a873c5a 100644 --- a/website/client/js/services/pusherService.js +++ b/website/client/js/services/pusherService.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Pusher', ['$rootScope', 'STORAGE_SETTINGS_ID', 'Groups', 'Shared', - function($rootScope, STORAGE_SETTINGS_ID, Groups, Shared) { +.factory('Pusher', ['$rootScope', 'STORAGE_SETTINGS_ID', 'Groups', 'Shared', '$state', 'Chat', 'Notification', + function($rootScope, STORAGE_SETTINGS_ID, Groups, Shared, $state, Chat, Notification) { var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); var IS_PUSHER_ENABLED = window.env['PUSHER:ENABLED'] === 'true'; @@ -16,7 +16,9 @@ angular.module('habitrpg') var tabIdKey = 'habitica-active-tab'; var tabId = Shared.uuid(); - function connectToPusher (partyId) { + + function connectToPusher (partyId, reconnecting) { + // Limit 1 tab connected per user localStorage.setItem(tabIdKey, tabId); window.onbeforeunload = function () { localStorage.removeItem(tabIdKey); @@ -54,11 +56,9 @@ angular.module('habitrpg') }); api.pusher.connection.bind('connected', function () { - api.socketId = api.pusher.connection.socket_id; + $rootScope.pusherSocketId = api.socketId = api.pusher.connection.socket_id; }); - if (!partyId) return; - var partyChannelName = 'presence-group-' + partyId; var partyChannel = api.pusher.subscribe(partyChannelName); @@ -68,18 +68,49 @@ angular.module('habitrpg') }); // When the user correctly enters the party channel - partyChannel.bind('pusher:subscription_succeeded', function(members) { - // TODO members = [{id, info}] - }); + partyChannel.bind('pusher:subscription_succeeded', function(pusherMembers) { + // Wait for the party to be loaded + Groups.party(reconnecting).then(function (party) { + // If we just reconnected after some inactivity, sync the party + if (reconnecting === true) { + _.assign($rootScope.party, party); + $rootScope.loadingParty = false; // make sure the party is set as loaded + } - // When a member enters the party channel - partyChannel.bind('pusher:member_added', function(member) { - // TODO member = {id, info} - }); + $rootScope.party.onlineUsers = pusherMembers.count; + + $rootScope.party.members.forEach(function (member) { + if (pusherMembers.members[member._id]) { + member.online = true; + } + }); + }); + + // When a member enters the party channel + partyChannel.bind('pusher:member_added', function(pusherMember) { + $rootScope.$apply(function() { + $rootScope.party.members.find(function (partyMember) { + if (partyMember._id === pusherMember.id) { + partyMember.online = true; + return true; + } + }); + $rootScope.party.onlineUsers++; + }); + }); - // When a member leaves the party channel - partyChannel.bind('pusher:member_removed', function(member) { - // TODO member = {id, info} + // When a member leaves the party channel + partyChannel.bind('pusher:member_removed', function(pusherMember) { + $rootScope.$apply(function() { + $rootScope.party.onlineUsers--; + $rootScope.party.members.find(function (partyMember) { + if (partyMember._id === pusherMember.id) { + partyMember.online = false; + return true; + } + }); + }); + }); }); // When the user is booted from the party, they get disconnected from Pusher @@ -97,10 +128,56 @@ angular.module('habitrpg') }); // When a new chat message is posted - partyChannel.bind('new-chat', function (data) { - Groups.party().then(function () { - // Groups.data.party.chat.unshift(data); - // Groups.data.party.chat.splice(200); + partyChannel.bind('new-chat', function (chatData) { + Groups.party().then(function () { // wait for the party to be fully loaded + $rootScope.loadingParty = false; // make sure the party is set as loaded + + // Update the party data + Groups.data.party.chat.unshift(chatData); + Groups.data.party.chat.splice(200); + + // If a system message comes in, sync the party as quest status may have changed + if (chatData.uuid === 'system') { + Groups.party(true).then(function (syncedParty) { + // Assign and not replace so that all the references get the modifications + _.assign($rootScope.party, syncedParty); + }); + } + + var docHasFocus = document.hasFocus(); + var isOnPartyPage = $state.is('options.social.party'); + + if (isOnPartyPage && docHasFocus) { // if we're on the party page, mark the chat as read + Chat.markChatSeen($rootScope.party._id); + } else { // show a notification + $rootScope.User.user.newMessages[$rootScope.party._id] = { + name: $rootScope.party.name, + value: true, + }; + + if ('Notification' in window && window.Notification.permission === 'granted') { + var notif = new window.Notification(env.t('newChatMessageTitle', { + groupName: $rootScope.party.name, + }), { + body: (chatData.user || chatData.uuid) + ': ' + chatData.text, + icon: '/common/img/gryphon_192-20.png' + }); + + notif.addEventListener('click', function () { + if (!isOnPartyPage) $state.go('options.social.party'); + if (!docHasFocus) Chat.markChatSeen($rootScope.party._id); + notif.close(); + }); + } else { + Notification.text(env.t('newChatMessagePlainNotification', { + groupName: $rootScope.party.name, + authorName: chatData.user || chatData.uuid, + }), function() { + if (!isOnPartyPage) $state.go('options.social.party'); + if (!docHasFocus) Chat.markChatSeen($rootScope.party._id); + }); + } + } }); }); }; @@ -111,7 +188,7 @@ angular.module('habitrpg') var awaitActivity = function() { $(document).off('mousemove keydown mousedown touchstart', awaitActivity); if (!localStorage.getItem(tabIdKey) || localStorage.getItem(tabIdKey) === tabId) { - connectToPusher(partyId); + connectToPusher(partyId, true); } }; @@ -142,7 +219,7 @@ angular.module('habitrpg') if (e.key === tabIdKey && e.newValue === null) { setTimeout(function () { if (!localStorage.getItem(tabIdKey)) { - connectToPusher(partyId); + connectToPusher(partyId, true); } }, Math.floor(Math.random() * 501) + 100); } diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index 74e3663637..48a138d5da 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -327,7 +327,7 @@ api.pusherAuth = { // Channel names are in the form of {presence|private}-{group|...}-{resourceId} let [channelType, resourceType, ...resourceId] = channelName.split('-'); - if (['presence'].indexOf(channelType) === -1) { // presence is used only for parties, private for guilds too + if (['presence'].indexOf(channelType) === -1) { // presence is used only for parties, private for guilds throw new BadRequest('Invalid Pusher channel type.'); } diff --git a/website/views/index.jade b/website/views/index.jade index 77c44c3c06..e2356e04a3 100644 --- a/website/views/index.jade +++ b/website/views/index.jade @@ -4,9 +4,9 @@ html(ng-app='habitrpg', ng-controller='RootCtrl', ng-class='{"applying-action":a head title(ng-bind="env.t('habitica') + ' | ' + $root.pageTitle") // ?v=1 needed to force refresh - link(rel='shortcut icon', sizes="32x32", href='#{env.getBuildUrl("favicon.ico")}?v=4') - link(rel='shortcut icon', sizes="144x144", href='#{env.getBuildUrl("favicon_192x192.png")}?v=4') - link(rel='mask-icon', href='#{env.getBuildUrl("favicon.ico")}?v=4') + link(rel='shortcut icon', sizes="32x32", href='#{env.getBuildUrl("favicon.ico")}') + link(rel='shortcut icon', sizes="144x144", href='#{env.getBuildUrl("favicon_192x192.png")}') + link(rel='mask-icon', href='#{env.getBuildUrl("favicon.ico")}') meta(charset='utf-8') meta(name='viewport', content='width=device-width, initial-scale=1.0') diff --git a/website/views/options/social/chat-box.jade b/website/views/options/social/chat-box.jade index 3792070706..a2282b165a 100644 --- a/website/views/options/social/chat-box.jade +++ b/website/views/options/social/chat-box.jade @@ -4,7 +4,7 @@ div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAcce div button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') .chat-buttons - button(type="button", ng-click='sync(group)')=env.t('toolTipMsg') + button(type="button", ng-click='sync(group)') {{group.type === 'party' ? env.t('syncPartyAndChat') : env.t('toolTipMsg')}} form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') div(ng-controller='AutocompleteCtrl') @@ -16,7 +16,7 @@ form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postCha .chat-controls.clearfix .chat-buttons input.btn(type='submit', value=env.t('sendChat'), ng-disabled='_sending') - button.btn(type="button", ng-click='sync(group)', ng-disabled='_sending')=env.t('toolTipMsg') + button.btn(type="button", ng-click='sync(group)', ng-disabled='_sending') {{group.type === 'party' ? env.t('syncPartyAndChat') : env.t('toolTipMsg')}} include ../../shared/formatting-help .chat-controls.clearfix .chat-buttons diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index 4eb78e45a9..2d1b541742 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -69,6 +69,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter .panel-heading h3.panel-title =env.t('members') + span(ng-if='group.type=="party"')= ' (' + env.t('onlineCount', {count: "{{group.onlineUsers}}"}) + ')' button.pull-right.btn.btn-primary(ng-click="openInviteModal(group)")=env.t("inviteFriends") .panel-body.modal-fixed-height @@ -84,7 +85,9 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter span(ng-click='clickMember(member._id, true)') | {{member.profile.name}} span(ng-if='group.type === "party"') - | (#[strong {{member.stats.hp.toFixed(1)}}] #{env.t('hp')}) + | (#[strong {{member.stats.hp.toFixed(1)}}] #{env.t('hp')}) {{member.id === user.id ? ' (' + env.t('you') + ') ' : ''}} + .pull-right(ng-if='group.type === "party"') + span.text-success {{member.online ? '● ' + env.t('online') : ''}} tr(ng-if='::group.memberCount > group.members.length') td span.badge {{group.memberCount - group.members.length}}