diff --git a/public/src/app.js b/public/src/app.js index a66f0c4151..c0c5fb6332 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -1,5 +1,5 @@ "use strict"; -/*global io, templates, ajaxify, utils, bootbox, overrides, socket, config, Visibility*/ +/*global templates, translator, ajaxify, utils, bootbox, overrides, socket, config, Visibility*/ var app = app || {}; @@ -159,7 +159,7 @@ app.cacheBuster = null; } app.currentRoom = ''; }); - } + }; function highlightNavigationLink() { var path = window.location.pathname; @@ -239,11 +239,7 @@ app.cacheBuster = null; } }; - app.openChat = function (username, touid) { - if (username === app.user.username) { - return app.alertError('[[error:cant-chat-with-yourself]]'); - } - + app.openChat = function (roomId) { if (!app.user.uid) { return app.alertError('[[error:not-logged-in]]'); } @@ -255,14 +251,35 @@ app.cacheBuster = null; chat.focusInput(chatModal); } - if (!chat.modalExists(touid)) { - chat.createModal({ - username: username, - touid: touid - }, loadAndCenter); + if (chat.modalExists(roomId)) { + loadAndCenter(chat.getModal(roomId)); } else { - loadAndCenter(chat.getModal(touid)); + socket.emit('modules.chats.getUsersInRoom', {roomId: roomId}, function(err, users) { + if (err) { + return app.alertError(err.message); + } + users = users.filter(function(user) { + return user && parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); + }); + chat.createModal({ + roomId: roomId, + users: users + }, loadAndCenter); + }); + } + }); + }; + + app.newChat = function (touid) { + if (!app.user.uid) { + return app.alertError('[[error:not-logged-in]]'); + } + + socket.emit('modules.chats.newRoom', {touid: touid}, function(err, roomId) { + if (err) { + return app.alertError(err.message); } + app.openChat(roomId); }); }; @@ -408,7 +425,7 @@ app.cacheBuster = null; function handleStatusChange() { $('[component="header/usercontrol"] [data-status]').off('click').on('click', function(e) { var status = $(this).attr('data-status'); - socket.emit('user.setStatus', status, function(err, data) { + socket.emit('user.setStatus', status, function(err) { if(err) { return app.alertError(err.message); } diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js index b67f9dd4a9..9c04d4a84d 100644 --- a/public/src/client/account/header.js +++ b/public/src/client/account/header.js @@ -30,7 +30,7 @@ define('forum/account/header', [ }); components.get('account/chat').on('click', function() { - app.openChat($('.account-username').html(), theirid); + app.newChat(theirid); }); components.get('account/ban').on('click', banAccount); diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 90b5e912ed..be9b76a050 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -1,6 +1,6 @@ 'use strict'; -/* globals define, config, app, ajaxify, utils, socket, templates */ +/* globals define, config, app, ajaxify, utils, socket, templates, Mousetrap, bootbox */ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', 'translator'], function(components, S, sounds, infinitescroll, translator) { var Chats = { @@ -18,6 +18,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', } Chats.addEventListeners(); + Chats.createTagsInput(ajaxify.data.roomId, ajaxify.data.users); if (env === 'md' || env === 'lg') { Chats.resizeMainWindow(); @@ -28,39 +29,30 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', Chats.initialised = true; - if (ajaxify.data.hasOwnProperty('meta') && ajaxify.data.meta.hasOwnProperty('uid')) { - // This is an active chat, focus on the input box + if (ajaxify.data.hasOwnProperty('roomId')) { components.get('chat/input').focus(); } }; - Chats.getRecipientUid = function() { - return parseInt($('.expanded-chat').attr('data-uid'), 10); - }; - - Chats.isCurrentChat = function(uid) { - return Chats.getRecipientUid() === parseInt(uid, 10); - }; - Chats.addEventListeners = function() { - components.get('chat/recent').on('click', 'li', function(e) { - Chats.switchChat(parseInt($(this).attr('data-uid'), 10), $(this).attr('data-username')); + $('[component="chat/recent"]').on('click', '[component="chat/recent/room"]', function() { + Chats.switchChat($(this).attr('data-roomid')); }); - Chats.addSendHandlers(Chats.getRecipientUid(), $('.chat-input'), $('.expanded-chat button[data-action="send"]')); + Chats.addSendHandlers(ajaxify.data.roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]')); $('[data-action="pop-out"]').on('click', function() { - var username = $('.expanded-chat').attr('data-username'), - uid = Chats.getRecipientUid(), - text = components.get('chat/input').val(); + + var text = components.get('chat/input').val(); + var roomId = ajaxify.data.roomId; if (app.previousUrl && app.previousUrl.match(/chats/)) { ajaxify.go('chats', function() { - app.openChat(username, uid); + app.openChat(roomId); }, true); } else { window.history.go(-1); - app.openChat(username, uid); + app.openChat(roomId); } $(window).one('action:chat.loaded', function() { @@ -72,14 +64,14 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', .on('click', '[data-action="edit"]', function() { var messageId = $(this).parents('[data-mid]').attr('data-mid'); var inputEl = components.get('chat/input'); - Chats.prepEdit(inputEl, messageId); + Chats.prepEdit(inputEl, messageId, ajaxify.data.roomId); }) .on('click', '[data-action="delete"]', function() { var messageId = $(this).parents('[data-mid]').attr('data-mid'); - Chats.delete(messageId); + Chats.delete(messageId, ajaxify.data.roomId); }); - $('.recent-chats').on('scroll', function() { + $('[component="chat/recent"]').on('scroll', function() { var $this = $(this); var bottom = ($this[0].scrollHeight - $this.height()) * 0.9; if ($this.scrollTop() > bottom) { @@ -87,7 +79,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', } }); - Chats.addSinceHandler(Chats.getRecipientUid(), $('.expanded-chat .chat-content'), $('.expanded-chat [data-since]')); + Chats.addSinceHandler(ajaxify.data.roomId, $('.expanded-chat .chat-content'), $('.expanded-chat [data-since]')); }; Chats.addHotkeys = function() { @@ -96,7 +88,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', prev = activeContact.prev(); if (prev.length) { - Chats.switchChat(parseInt(prev.attr('data-uid'), 10), prev.attr('data-username')); + Chats.switchChat(prev.attr('data-roomid')); } }); Mousetrap.bind('ctrl+down', function() { @@ -104,7 +96,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', next = activeContact.next(); if (next.length) { - Chats.switchChat(parseInt(next.attr('data-uid'), 10), next.attr('data-username')); + Chats.switchChat(next.attr('data-roomid')); } }); Mousetrap.bind('up', function(e) { @@ -114,13 +106,16 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', var lastMid = message.attr('data-mid'); var inputEl = components.get('chat/input'); - Chats.prepEdit(inputEl, lastMid); + Chats.prepEdit(inputEl, lastMid, ajaxify.data.roomId); } }); }; - Chats.prepEdit = function(inputEl, messageId) { - socket.emit('modules.chats.getRaw', { mid: messageId }, function(err, raw) { + Chats.prepEdit = function(inputEl, messageId, roomId) { + socket.emit('modules.chats.getRaw', { mid: messageId, roomId: roomId }, function(err, raw) { + if (err) { + return app.alertError(err.message); + } // Populate the input field with the raw message content if (inputEl.val().length === 0) { // By setting the `data-mid` attribute, I tell the chat code that I am editing a @@ -131,42 +126,44 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }); }; - Chats.delete = function(messageId) { + Chats.delete = function(messageId, roomId) { translator.translate('[[modules:chat.delete_message_confirm]]', function(translated) { bootbox.confirm(translated, function(ok) { - if (ok) { - socket.emit('modules.chats.delete', { - messageId: messageId - }, function(err) { - if (err) { - return app.alertError(err.message); - } - - // Remove message from list - components.get('chat/message', messageId).slideUp('slow', function() { - $(this).remove(); - }); - }); + if (!ok) { + return; } + + socket.emit('modules.chats.delete', { + messageId: messageId, + roomId: roomId + }, function(err) { + if (err) { + return app.alertError(err.message); + } + + components.get('chat/message', messageId).slideUp('slow', function() { + $(this).remove(); + }); + }); }); }); }; - Chats.addSinceHandler = function(toUid, chatContentEl, sinceEl) { + Chats.addSinceHandler = function(roomId, chatContentEl, sinceEl) { sinceEl.on('click', function() { var since = $(this).attr('data-since'); sinceEl.removeClass('selected'); $(this).addClass('selected'); - Chats.loadChatSince(toUid, chatContentEl, since); + Chats.loadChatSince(roomId, chatContentEl, since); return false; }); }; - Chats.addSendHandlers = function(toUid, inputEl, sendEl) { + Chats.addSendHandlers = function(roomId, inputEl, sendEl) { inputEl.off('keypress').on('keypress', function(e) { if (e.which === 13 && !e.shiftKey) { - Chats.sendMessage(toUid, inputEl); + Chats.sendMessage(roomId, inputEl); return false; } }); @@ -177,51 +174,66 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', return; } - Chats.notifyTyping(toUid, val); + Chats.notifyTyping(roomId, val); $(this).attr('data-typing', val); }); - sendEl.off('click').on('click', function(e) { - Chats.sendMessage(toUid, inputEl); + sendEl.off('click').on('click', function() { + Chats.sendMessage(roomId, inputEl); inputEl.focus(); return false; }); }; - Chats.switchChat = function(uid, username) { - if (!$('#content [component="chat/messages"]').length) { - return ajaxify.go('chats/' + utils.slugify(username)); + Chats.createTagsInput = function(roomId, users) { + var tagEl = $('.users-tag-input'); + + tagEl.tagsinput({ + confirmKeys: [13, 44], + trimValue: true + }); + + if (users && users.length) { + users.forEach(function(user) { + tagEl.tagsinput('add', user.username); + }); } - var contactEl = $('.chats-list [data-uid="' + uid + '"]'); + tagEl.on('itemAdded', function(event) { + if (event.item === app.user.username) { + return; + } + socket.emit('modules.chats.addUserToRoom', {roomId: roomId, username: event.item}, function(err) { + if (err && err.message === '[[error:no-user]]') { + tagEl.tagsinput('remove', event.item); + } + }); + }); - Chats.loadChatSince(uid, $('.chat-content'), 'recent'); - Chats.addSendHandlers(uid, components.get('chat/input'), $('[data-action="send"]')); + tagEl.on('beforeItemRemove', function(event) { + event.cancel = !ajaxify.data.owner; + }); - contactEl - .removeClass('unread') - .addClass('bg-primary') - .siblings().removeClass('bg-primary'); + tagEl.on('itemRemoved', function(event) { + socket.emit('modules.chats.removeUserFromRoom', {roomId: roomId, username: event.item}); + }); - components.get('chat/title').text(username); - components.get('chat/messages').attr('data-uid', uid).attr('data-username', username); - components.get('breadcrumb/current').text(username); - components.get('chat/input').focus(); + var input = $('.users-tag-container').find('.bootstrap-tagsinput input'); - if (window.history && window.history.pushState) { - var url = 'chats/' + utils.slugify(username); + require(['autocomplete'], function(autocomplete) { + autocomplete.user(input); + }); + }; - window.history.pushState({ - url: url - }, url, RELATIVE_PATH + '/' + url); - } + Chats.switchChat = function(roomid) { + ajaxify.go('chats/' + roomid); }; - Chats.loadChatSince = function(toUid, chatContentEl, since) { - if (!toUid) { + Chats.loadChatSince = function(roomId, chatContentEl, since) { + if (!roomId) { return; } - socket.emit('modules.chats.get', {touid: toUid, since: since}, function(err, messages) { + socket.emit('modules.chats.get', {roomId: roomId, since: since}, function(err, messages) { if (err) { return app.alertError(err.message); } @@ -235,12 +247,9 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', Chats.addGlobalEventListeners = function() { $(window).on('resize', Chats.resizeMainWindow); $(window).on('mousemove keypress click', function() { - if (newMessage) { - var recipientUid = Chats.getRecipientUid(); - if (recipientUid) { - socket.emit('modules.chats.markRead', recipientUid); - newMessage = false; - } + if (newMessage && ajaxify.data.roomId) { + socket.emit('modules.chats.markRead', ajaxify.data.roomId); + newMessage = false; } }); }; @@ -268,42 +277,11 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', Chats.addSocketListeners = function() { socket.on('event:chats.receive', function(data) { - if (Chats.isCurrentChat(data.withUid)) { + if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { newMessage = data.self === 0; data.message.self = data.self; Chats.appendChatMessage($('.expanded-chat .chat-content'), data.message); - } else { - var contactEl = $('[component="chat/recent"] li[data-uid="' + data.withUid + '"]'), - userKey = parseInt(data.withUid, 10) === parseInt(data.message.fromuid, 10) ? 'fromUser' : 'toUser'; - - // Spawn a new contact if required - templates.parse('partials/chat_contacts', { - contacts: [{ - uid: data.message[userKey].uid, - username: data.message[userKey].username, - status: data.message[userKey].status, - picture: data.message[userKey].picture, - 'icon:text': data.message[userKey]['icon:text'], - 'icon:bgColor': data.message[userKey]['icon:bgColor'], - teaser: { - content: data.message.cleanedContent, - timestampISO: new Date(Date.now()).toISOString() - } - }] - }, function(html) { - translator.translate(html, function(translatedHTML) { - if (contactEl.length) { - contactEl.replaceWith(translatedHTML); - } else { - $('[component="chat/recent"]').prepend(translatedHTML); - } - - // Mark that contact list entry unread - $('.chats-list li[data-uid="' + data.withUid + '"]').addClass('unread').find('.timeago').timeago(); - app.alternatingTitle('[[modules:chat.user_has_messaged_you, ' + data.message.fromUser.username + ']]'); - }); - }); } }); @@ -320,13 +298,12 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }); socket.on('event:chats.edit', function(data) { - var message; data.messages.forEach(function(message) { templates.parse('partials/chat_message', { messages: message }, function(html) { - body = components.get('chat/message', message.messageId); + var body = components.get('chat/message', message.messageId); if (body.length) { body.replaceWith(html); components.get('chat/message', message.messageId).find('.timeago').timeago(); @@ -351,14 +328,14 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', Chats.setActive(); }; - Chats.notifyTyping = function(toUid, typing) { + Chats.notifyTyping = function(roomId, typing) { socket.emit('modules.chats.user' + (typing ? 'Start' : 'Stop') + 'Typing', { - touid: toUid, + roomId: roomId, fromUid: app.user.uid }); }; - Chats.sendMessage = function(toUid, inputEl) { + Chats.sendMessage = function(roomId, inputEl) { var msg = inputEl.val(), mid = inputEl.attr('data-mid'); @@ -375,7 +352,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', if (!mid) { socket.emit('modules.chats.send', { - touid: toUid, + roomId: roomId, message: msg }, function(err) { if (err) { @@ -386,10 +363,11 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', } sounds.play('chat-outgoing'); - Chats.notifyTyping(toUid, false); + Chats.notifyTyping(roomId, false); }); } else { socket.emit('modules.chats.edit', { + roomId: roomId, mid: mid, message: msg }, function(err) { @@ -397,7 +375,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', return app.alertError(err.message); } - Chats.notifyTyping(toUid, false); + Chats.notifyTyping(roomId, false); }); } }; @@ -411,13 +389,12 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }; Chats.setActive = function() { - var recipientUid = Chats.getRecipientUid(); - if (recipientUid) { - socket.emit('modules.chats.markRead', recipientUid); + if (ajaxify.data.roomId) { + socket.emit('modules.chats.markRead', ajaxify.data.roomId); $('.expanded-chat input').focus(); } $('.chats-list li').removeClass('bg-primary'); - $('.chats-list li[data-uid="' + recipientUid + '"]').addClass('bg-primary'); + $('.chats-list li[data-roomid="' + ajaxify.data.roomId + '"]').addClass('bg-primary'); }; Chats.parseMessage = function(data, callback) { @@ -429,7 +406,7 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }; function loadMoreRecentChats() { - var recentChats = $('.recent-chats'); + var recentChats = $('[component="chat/recent"]'); if (recentChats.attr('loading')) { return; } @@ -441,8 +418,8 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', return app.alertError(err.message); } - if (data && data.users.length) { - onRecentChatsLoaded(data.users, function() { + if (data && data.rooms.length) { + onRecentChatsLoaded(data, function() { recentChats.removeAttr('loading'); recentChats.attr('data-nextstart', data.nextStart); }); @@ -452,17 +429,14 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }); } - function onRecentChatsLoaded(users, callback) { - users = users.filter(function(user) { - return !$('.recent-chats li[data-uid=' + user.uid + ']').length; - }); - - if (!users.length) { + function onRecentChatsLoaded(data, callback) { + if (!data.rooms.length) { return callback(); } - app.parseAndTranslate('chats', 'chats', {chats: users}, function(html) { - $('.recent-chats').append(html); + app.parseAndTranslate('chats', 'rooms', data, function(html) { + $('[component="chat/recent"]').append(html); + html.find('.timeago').timeago(); callback(); }); } diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index c971a7e5fc..0ae05ec7a5 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -405,12 +405,10 @@ define('forum/topic/postTools', ['share', 'navigator', 'components', 'translator }); } - - function openChat(button) { var post = button.parents('[data-pid]'); - app.openChat(post.attr('data-username'), post.attr('data-uid')); + app.newChat(post.attr('data-uid')); button.parents('.btn-group').find('.dropdown-toggle').click(); return false; } diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index a66fff7264..ecf15c2ead 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -10,6 +10,9 @@ define('autocomplete', function() { app.loadJQueryUI(function() { input.autocomplete({ delay: 100, + open: function() { + $(this).autocomplete('widget').css('z-index', 20000); + }, select: onselect, source: function(request, response) { socket.emit('user.search', {query: request.term}, function(err, result) { diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 14bf5982c5..a3d4620f08 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -1,5 +1,5 @@ "use strict"; -/* globals app, config, define, socket, templates, utils, ajaxify */ +/* globals app, define, socket, templates, utils, ajaxify */ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'translator'], function(components, taskbar, S, sounds, Chats, translator) { @@ -20,14 +20,12 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra socket.on('event:chats.receive', function(data) { var username = data.message.fromUser.username; - var isSelf = parseInt(data.message.fromUser.uid, 10) === parseInt(app.user.uid, 10); + var isSelf = data.self === 1; data.message.self = data.self; - if (isSelf) { - username = data.message.toUser.username; - } + newMessage = data.self === 0; - if (module.modalExists(data.withUid)) { - var modal = module.getModal(data.withUid); + if (module.modalExists(data.roomId)) { + var modal = module.getModal(data.roomId); Chats.appendChatMessage(modal.find('.chat-content'), data.message); @@ -49,8 +47,7 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra } } else { module.createModal({ - username: username, - touid: data.withUid, + roomId: data.roomId, silent: true }, function(modal) { module.toggleNew(modal.attr('UUID'), true, true); @@ -90,15 +87,16 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra module.loadChatsDropdown = function(chatsListEl) { var dropdownEl; - socket.emit('modules.chats.getRecentChats', {after: 0}, function(err, chats) { + socket.emit('modules.chats.getRecentChats', {after: 0}, function(err, data) { if (err) { return app.alertError(err.message); } - chats = chats.users; + + var rooms = data.rooms; chatsListEl.empty(); - if (!chats.length) { + if (!rooms.length) { translator.translate('[[modules:chat.no_active]]', function(str) { $('
  • ') .addClass('no_active') @@ -108,23 +106,29 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra return; } - chats.forEach(function(userObj) { - dropdownEl = $('
  • ') - .attr('data-uid', userObj.uid) - .html(''+ + rooms.forEach(function(roomObj) { + function createUserImage(userObj) { + return '' + (userObj.picture ? '' : '
    ' + userObj['icon:text'] + '
    ') + ' ' + - userObj.username + '
    ') + roomObj.usernames + ''; + } + + dropdownEl = $('
  • ') + .attr('data-roomId', roomObj.roomId) .appendTo(chatsListEl); + if (roomObj.lastUser) { + dropdownEl.append(createUserImage(roomObj.lastUser)); + } dropdownEl.click(function() { if (!ajaxify.currentPage.match(/^chats\//)) { - app.openChat(userObj.username, userObj.uid); + app.openChat(roomObj.roomId); } else { - ajaxify.go('chats/' + utils.slugify(userObj.username)); + ajaxify.go('chats/' + roomObj.roomId); } }); }); @@ -149,12 +153,12 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra chatModal.css('zIndex', topZ + 1); }; - module.getModal = function(touid) { - return $('#chat-modal-' + touid); + module.getModal = function(roomId) { + return $('#chat-modal-' + roomId); }; - module.modalExists = function(touid) { - return $('#chat-modal-' + touid).length !== 0; + module.modalExists = function(roomId) { + return $('#chat-modal-' + roomId).length !== 0; }; function checkStatus(chatModal) { @@ -175,8 +179,8 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra uuid = utils.generateUUID(), dragged = false; - chatModal.attr('id', 'chat-modal-' + data.touid); - chatModal.attr('touid', data.touid); + chatModal.attr('id', 'chat-modal-' + data.roomId); + chatModal.attr('roomId', data.roomId); chatModal.attr('intervalId', 0); chatModal.attr('UUID', uuid); chatModal.css('position', 'fixed'); @@ -210,8 +214,6 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra }); }); - chatModal.find('#chat-with-name').html(data.username); - chatModal.find('#chat-close-btn').on('click', function() { module.close(chatModal); }); @@ -222,19 +224,17 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra components.get('chat/input').val(text); }); - ajaxify.go('chats/' + utils.slugify(data.username)); + ajaxify.go('chats/' + chatModal.attr('roomId')); module.close(chatModal); } chatModal.find('.modal-header').on('dblclick', gotoChats); chatModal.find('button[data-action="maximize"]').on('click', gotoChats); - chatModal.on('click', function(e) { + chatModal.on('click', function() { module.bringModalToTop(chatModal); - if (!dragged) { - chatModal.find('#chat-message-input').focus(); - } else { + if (dragged) { dragged = false; } }); @@ -247,7 +247,7 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra chatModal.on('mousemove keypress click', function() { if (newMessage) { - socket.emit('modules.chats.markRead', data.touid); + socket.emit('modules.chats.markRead', data.roomId); newMessage = false; } }); @@ -256,22 +256,24 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra .on('click', '[data-action="edit"]', function() { var messageId = $(this).parents('[data-mid]').attr('data-mid'); var inputEl = chatModal.find('[component="chat/input"]'); - Chats.prepEdit(inputEl, messageId); + Chats.prepEdit(inputEl, messageId, data.roomId); }) .on('click', '[data-action="delete"]', function() { var messageId = $(this).parents('[data-mid]').attr('data-mid'); - Chats.delete(messageId); + Chats.delete(messageId, data.roomId); }); - Chats.addSinceHandler(chatModal.attr('touid'), chatModal.find('.chat-content'), chatModal.find('[data-since]')); + Chats.addSinceHandler(chatModal.attr('roomId'), chatModal.find('.chat-content'), chatModal.find('[data-since]')); - Chats.addSendHandlers(chatModal.attr('touid'), chatModal.find('#chat-message-input'), chatModal.find('#chat-message-send-btn')); + Chats.addSendHandlers(chatModal.attr('roomId'), chatModal.find('#chat-message-input'), chatModal.find('#chat-message-send-btn')); - Chats.loadChatSince(chatModal.attr('touid'), chatModal.find('.chat-content'), 'recent'); + Chats.createTagsInput(data.roomId, data.users); + + Chats.loadChatSince(chatModal.attr('roomId'), chatModal.find('.chat-content'), 'recent'); checkStatus(chatModal); - module.canMessage(data.touid, function(err) { + module.canMessage(data.roomId, function(err) { if (err) { // Disable the text input chatModal.find('input[type="text"]').attr('disabled', true); @@ -279,8 +281,8 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra }); taskbar.push('chat', chatModal.attr('UUID'), { - title: data.username, - touid: data.touid, + title: data.users.length ? data.users[0].username : '', + roomId: data.roomId, icon: 'fa-comment', state: '' }); @@ -304,7 +306,7 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra chatModal.remove(); chatModal.data('modal', null); taskbar.discard('chat', chatModal.attr('UUID')); - Chats.notifyTyping(chatModal.attr('touid'), false); + Chats.notifyTyping(chatModal.attr('roomId'), false); if (chatModal.attr('data-mobile')) { module.disableMobileBehaviour(chatModal); @@ -327,14 +329,14 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra }; module.load = function(uuid) { - var chatModal = $('div[UUID="'+uuid+'"]'); + var chatModal = $('div[UUID="' + uuid + '"]'); chatModal.removeClass('hide'); checkStatus(chatModal); taskbar.updateActive(uuid); Chats.scrollToBottom(chatModal.find('.chat-content')); module.bringModalToTop(chatModal); module.focusInput(chatModal); - socket.emit('modules.chats.markRead', chatModal.attr('touid')); + socket.emit('modules.chats.markRead', chatModal.attr('roomId')); var env = utils.findBootstrapEnvironment(); if (env === 'xs' || env === 'sm') { @@ -373,13 +375,13 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra taskbar.minimize('chat', uuid); clearInterval(chatModal.attr('intervalId')); chatModal.attr('intervalId', 0); - Chats.notifyTyping(chatModal.attr('touid'), false); + Chats.notifyTyping(chatModal.attr('roomId'), false); }; module.toggleNew = taskbar.toggleNew; - module.canMessage = function(toUid, callback) { - socket.emit('modules.chats.canMessage', toUid, callback); + module.canMessage = function(roomId, callback) { + socket.emit('modules.chats.canMessage', roomId, callback); }; diff --git a/src/controllers/accounts/chats.js b/src/controllers/accounts/chats.js index 1f95db984b..2c777e7266 100644 --- a/src/controllers/accounts/chats.js +++ b/src/controllers/accounts/chats.js @@ -1,13 +1,11 @@ 'use strict'; -var async = require('async'), - nconf = require('nconf'), +var async = require('async'); + +var messaging = require('../../messaging'); +var meta = require('../../meta'); +var helpers = require('../helpers'); - user = require('../../user'), - messaging = require('../../messaging'), - meta = require('../../meta'), - helpers = require('../helpers'), - utils = require('../../../public/src/utils'); var chatsController = {}; @@ -16,35 +14,15 @@ chatsController.get = function(req, res, callback) { return callback(); } - // In case a userNAME is passed in instead of a slug, the route should not 404 - var slugified = utils.slugify(req.params.userslug); - if (req.params.userslug && req.params.userslug !== slugified) { - return helpers.redirect(res, '/chats/' + slugified); - } - - async.parallel({ - contacts: async.apply(user.getFollowing, req.user.uid, 0, 199), - recentChats: async.apply(messaging.getRecentChats, req.user.uid, 0, 19) - }, function(err, results) { + messaging.getRecentChats(req.uid, 0, 19, function(err, recentChats) { if (err) { return callback(err); } - if (results.recentChats.users && results.recentChats.users.length) { - var contactUids = results.recentChats.users.map(function(chatObj) { - return parseInt(chatObj.uid, 10); - }); - - results.contacts = results.contacts.filter(function(contact) { - return contactUids.indexOf(parseInt(contact.uid, 10)) === -1; - }); - } - - if (!req.params.userslug) { + if (!req.params.roomid) { return res.render('chats', { - chats: results.recentChats.users, - nextStart: results.recentChats.nextStart, - contacts: results.contacts, + rooms: recentChats.rooms, + nextStart: recentChats.nextStart, allowed: true, title: '[[pages:chats]]', breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:chats]]'}]) @@ -52,21 +30,24 @@ chatsController.get = function(req, res, callback) { } async.waterfall([ - async.apply(user.getUidByUserslug, req.params.userslug), - function(toUid, next) { - if (!toUid || parseInt(toUid, 10) === parseInt(req.user.uid, 10)) { + function (next) { + messaging.isUserInRoom(req.uid, req.params.roomid, next); + }, + function (inRoom, next) { + if (!inRoom) { return callback(); } async.parallel({ - toUser: async.apply(user.getUserFields, toUid, ['uid', 'username']), + users: async.apply(messaging.getUsersInRoom, req.params.roomid, 0, -1), messages: async.apply(messaging.getMessages, { - fromuid: req.user.uid, - touid: toUid, + uid: req.uid, + roomId: req.params.roomid, since: 'recent', isNew: false }), - allowed: async.apply(messaging.canMessage, req.user.uid, toUid) + allowed: async.apply(messaging.canMessageRoom, req.uid, req.params.roomid), + owner: async.apply(messaging.isRoomOwner, req.uid, req.params.roomid) }, next); } ], function(err, data) { @@ -74,16 +55,22 @@ chatsController.get = function(req, res, callback) { return callback(err); } - res.render('chats', { - chats: results.recentChats.users, - nextStart: results.recentChats.nextStart, - contacts: results.contacts, - meta: data.toUser, - messages: data.messages, - allowed: data.allowed, - title: '[[pages:chat, ' + data.toUser.username + ']]', - breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:chats]]', url: '/chats'}, {text: data.toUser.username}]) + data.users = data.users.filter(function(user) { + return user && parseInt(user.uid, 10) !== req.uid; }); + + data.usernames = data.users.map(function(user) { + return user && user.username; + }).join(', '); + + + data.roomId = req.params.roomid; + data.rooms = recentChats.rooms; + data.nextStart = recentChats.nextStart; + data.title = '[[pages:chat, ' + data.usernames + ']]'; + data.breadcrumbs = helpers.buildBreadcrumbs([{text: '[[pages:chats]]', url: '/chats'}, {text: data.roomId}]); + + res.render('chats', data); }); }); }; diff --git a/src/messaging.js b/src/messaging.js index bff12313ba..c7c8f26978 100644 --- a/src/messaging.js +++ b/src/messaging.js @@ -1,22 +1,27 @@ 'use strict'; -var db = require('./database'), - async = require('async'), - nconf = require('nconf'), + +var async = require('async'), winston = require('winston'), S = require('string'), + + db = require('./database'), user = require('./user'), plugins = require('./plugins'), meta = require('./meta'), utils = require('../public/src/utils'), notifications = require('./notifications'), - userNotifications = require('./user/notifications'), - emailer = require('./emailer'), - sockets = require('./socket.io'); + userNotifications = require('./user/notifications'); (function(Messaging) { - Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser + + require('./messaging/create')(Messaging); + require('./messaging/delete')(Messaging); + require('./messaging/edit')(Messaging); + require('./messaging/rooms')(Messaging); + require('./messaging/unread')(Messaging); + require('./messaging/notifications')(Messaging); var terms = { day: 86400000, @@ -25,147 +30,9 @@ var db = require('./database'), threemonths: 7776000000 }; - function sortUids(fromuid, touid) { - return [fromuid, touid].sort(); - } - - Messaging.addMessage = function(fromuid, touid, content, timestamp, callback) { - if (typeof timestamp === 'function') { - callback = timestamp; - timestamp = Date.now(); - } else { - timestamp = timestamp || Date.now(); - } - - if (!content) { - return callback(new Error('[[error:invalid-chat-message]]')); - } - - if (content.length > (meta.config.maximumChatMessageLength || 1000)) { - return callback(new Error('[[error:chat-message-too-long]]')); - } - - var uids = sortUids(fromuid, touid); - - db.incrObjectField('global', 'nextMid', function(err, mid) { - if (err) { - return callback(err); - } - var message = { - content: content, - timestamp: timestamp, - fromuid: fromuid, - touid: touid - }; - - async.waterfall([ - function(next) { - plugins.fireHook('filter:messaging.save', message, next); - }, - function(message, next) { - db.setObject('message:' + mid, message, next); - } - ], function(err) { - if (err) { - return callback(err); - } - - async.parallel([ - async.apply(db.sortedSetAdd, 'messages:uid:' + uids[0] + ':to:' + uids[1], timestamp, mid), - async.apply(Messaging.updateChatTime, fromuid, touid), - async.apply(Messaging.updateChatTime, touid, fromuid), - async.apply(Messaging.markRead, fromuid, touid), - async.apply(Messaging.markUnread, touid, fromuid), - ], function(err) { - if (err) { - return callback(err); - } - - async.waterfall([ - function(next) { - getMessages([mid], fromuid, touid, true, next); - }, - function(messages, next) { - Messaging.isNewSet(fromuid, touid, mid, function(err, isNewSet) { - if (err) { - return next(err); - } - - if (!messages || !messages[0]) { - return next(null, null); - } - - messages[0].newSet = isNewSet; - messages[0].mid = mid; - next(null, messages[0]); - }); - } - ], callback); - }); - }); - }); - }; - - Messaging.editMessage = function(mid, content, callback) { - async.series([ - function(next) { - // Verify that the message actually changed - Messaging.getMessageField(mid, 'content', function(err, raw) { - if (raw === content) { - // No dice. - return callback(); - } - - next(); - }); - }, - async.apply(Messaging.setMessageFields, mid, { - content: content, - edited: Date.now() - }), - function(next) { - Messaging.getMessageFields(mid, ['fromuid', 'touid'], function(err, data) { - getMessages([mid], data.fromuid, data.touid, true, function(err, messages) { - sockets.in('uid_' + data.fromuid).emit('event:chats.edit', { - messages: messages - }); - sockets.in('uid_' + data.touid).emit('event:chats.edit', { - messages: messages - }); - next(); - }); - }); - } - ], callback); - }; - - Messaging.deleteMessage = function(mid, callback) { - var uids = []; - async.series([ - function(next) { - db.getObject('message:' + mid, function(err, messageObj) { - messageObj.fromuid = parseInt(messageObj.fromuid, 10); - messageObj.touid = parseInt(messageObj.touid, 10); - uids.push(messageObj.fromuid, messageObj.touid); - uids.sort(function(a, b) { - return a > b ? 1 : -1; - }); - next(); - }); - }, - function(next) { - next(); - }, - function(next) { - db.sortedSetRemove('messages:uid:' + uids[0] + ':to:' + uids[1], mid, next); - }, - async.apply(db.delete, 'message:' + mid) - ], callback); - }; - Messaging.getMessageField = function(mid, field, callback) { Messaging.getMessageFields(mid, [field], function(err, fields) { - callback(err, fields[field]); + callback(err, fields ? fields[field] : null); }); }; @@ -182,22 +49,21 @@ var db = require('./database'), }; Messaging.getMessages = function(params, callback) { - var fromuid = params.fromuid, - touid = params.touid, + var uid = params.uid, + roomId = params.roomId, since = params.since, isNew = params.isNew, count = params.count || parseInt(meta.config.chatMessageInboxSize, 10) || 250, markRead = params.markRead || true; - var uids = sortUids(fromuid, touid), - min = params.count ? 0 : Date.now() - (terms[since] || terms.day); + var min = params.count ? 0 : Date.now() - (terms[since] || terms.day); if (since === 'recent') { count = 49; min = 0; } - db.getSortedSetRevRangeByScore('messages:uid:' + uids[0] + ':to:' + uids[1], 0, count, '+inf', min, function(err, mids) { + db.getSortedSetRevRangeByScore('uid:' + uid + ':chat:room:' + roomId + ':mids', 0, count, '+inf', min, function(err, mids) { if (err) { return callback(err); } @@ -208,115 +74,122 @@ var db = require('./database'), mids.reverse(); - getMessages(mids, fromuid, touid, isNew, callback); + Messaging.getMessagesData(mids, uid, roomId, isNew, callback); }); if (markRead) { - notifications.markRead('chat_' + touid + '_' + fromuid, fromuid, function(err) { + notifications.markRead('chat_' + roomId + '_' + uid, uid, function(err) { if (err) { winston.error('[messaging] Could not mark notifications related to this chat as read: ' + err.message); } - userNotifications.pushCount(fromuid); + userNotifications.pushCount(uid); }); } }; - function getMessages(mids, fromuid, touid, isNew, callback) { - user.getUsersFields([fromuid, touid], ['uid', 'username', 'userslug', 'picture', 'status'], function(err, userData) { - if(err) { - return callback(err); - } + Messaging.getMessagesData = function(mids, uid, roomId, isNew, callback) { - var keys = mids.map(function(mid) { - return 'message:' + mid; - }); + var keys = mids.map(function(mid) { + return 'message:' + mid; + }); - async.waterfall([ - async.apply(db.getObjects, keys), - function(messages, next) { - messages = messages.map(function(msg, idx) { - if (msg) { - msg.messageId = parseInt(mids[idx], 10); - } - return msg; - }).filter(Boolean); - async.map(messages, function(message, next) { - var self = parseInt(message.fromuid, 10) === parseInt(fromuid, 10); - message.fromUser = self ? userData[0] : userData[1]; - message.toUser = self ? userData[1] : userData[0]; - message.timestampISO = utils.toISOString(message.timestamp); - message.self = self ? 1 : 0; - message.newSet = false; - - if (message.hasOwnProperty('edited')) { - message.editedISO = new Date(parseInt(message.edited, 10)).toISOString(); - } + var messages; - Messaging.parse(message.content, message.fromuid, fromuid, userData[1], userData[0], isNew, function(result) { - message.content = result; - message.cleanedContent = S(result).stripTags().decodeHTMLEntities().s; - next(null, message); - }); - }, next); - }, - function(messages, next) { - if (messages.length > 1) { - // Add a spacer in between messages with time gaps between them - messages = messages.map(function(message, index) { - // Compare timestamps with the previous message, and check if a spacer needs to be added - if (index > 0 && parseInt(message.timestamp, 10) > parseInt(messages[index-1].timestamp, 10) + (1000*60*5)) { - // If it's been 5 minutes, this is a new set of messages - message.newSet = true; - } else if (index > 0 && message.fromuid !== messages[index-1].fromuid) { - // If the previous message was from the other person, this is also a new set - message.newSet = true; - } + async.waterfall([ + function (next) { + db.getObjects(keys, next); + }, + function (_messages, next) { + messages = _messages.map(function(msg, idx) { + if (msg) { + msg.messageId = parseInt(mids[idx], 10); + } + return msg; + }).filter(Boolean); - return message; - }); + var uids = messages.map(function(msg) { + return msg && msg.fromuid; + }); - next(undefined, messages); - } else { - // For single messages, we don't know the context, so look up the previous message and compare - var uids = [fromuid, touid].sort(function(a, b) { return a > b ? 1 : -1 }); - var key = 'messages:uid:' + uids[0] + ':to:' + uids[1]; - async.waterfall([ - async.apply(db.sortedSetRank, key, messages[0].messageId), - function(index, next) { - // Continue only if this isn't the first message in sorted set - if (index > 0) { - db.getSortedSetRange(key, index-1, index-1, next); - } else { - messages[0].newSet = true; - return next(undefined, messages); - } - }, - function(mid, next) { - Messaging.getMessageFields(mid, ['fromuid', 'timestamp'], next); - } - ], function(err, fields) { - if (err) { - return next(err); - } + user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status'], next); + }, + function (users, next) { + messages.forEach(function(message, index) { + message.fromUser = users[index]; + var self = parseInt(message.fromuid, 10) === parseInt(uid, 10); + message.self = self ? 1 : 0; + message.timestampISO = utils.toISOString(message.timestamp); + message.newSet = false; + if (message.hasOwnProperty('edited')) { + message.editedISO = new Date(parseInt(message.edited, 10)).toISOString(); + } + }); + + async.map(messages, function(message, next) { + Messaging.parse(message.content, message.fromuid, uid, roomId, isNew, function(result) { + message.content = result; + message.cleanedContent = S(result).stripTags().decodeHTMLEntities().s; + next(null, message); + }); + }, next); + }, + function(messages, next) { + if (messages.length > 1) { + // Add a spacer in between messages with time gaps between them + messages = messages.map(function(message, index) { + // Compare timestamps with the previous message, and check if a spacer needs to be added + if (index > 0 && parseInt(message.timestamp, 10) > parseInt(messages[index-1].timestamp, 10) + (1000*60*5)) { + // If it's been 5 minutes, this is a new set of messages + message.newSet = true; + } else if (index > 0 && message.fromuid !== messages[index-1].fromuid) { + // If the previous message was from the other person, this is also a new set + message.newSet = true; + } - if ( - (parseInt(messages[0].timestamp, 10) > parseInt(fields.timestamp, 10) + (1000*60*5)) || - (parseInt(messages[0].fromuid, 10) !== parseInt(fields.fromuid, 10)) - ) { - // If it's been 5 minutes, this is a new set of messages + return message; + }); + + next(undefined, messages); + } else { + // For single messages, we don't know the context, so look up the previous message and compare + var key = 'uid:' + uid + ':chat:room:' + roomId + ':mids'; + async.waterfall([ + async.apply(db.sortedSetRank, key, messages[0].messageId), + function(index, next) { + // Continue only if this isn't the first message in sorted set + if (index > 0) { + db.getSortedSetRange(key, index-1, index-1, next); + } else { messages[0].newSet = true; + return next(undefined, messages); } + }, + function(mid, next) { + Messaging.getMessageFields(mid, ['fromuid', 'timestamp'], next); + } + ], function(err, fields) { + if (err) { + return next(err); + } - next(undefined, messages); - }); - } + if ( + (parseInt(messages[0].timestamp, 10) > parseInt(fields.timestamp, 10) + (1000*60*5)) || + (parseInt(messages[0].fromuid, 10) !== parseInt(fields.fromuid, 10)) + ) { + // If it's been 5 minutes, this is a new set of messages + messages[0].newSet = true; + } + + next(undefined, messages); + }); } - ], callback); - }); - } + } + ], callback); - Messaging.parse = function (message, fromuid, myuid, toUserData, myUserData, isNew, callback) { + }; + + Messaging.parse = function (message, fromuid, uid, roomId, isNew, callback) { plugins.fireHook('filter:parse.raw', message, function(err, parsed) { if (err) { return callback(message); @@ -326,9 +199,8 @@ var db = require('./database'), message: message, parsed: parsed, fromuid: fromuid, - myuid: myuid, - toUserData: toUserData, - myUserData: myUserData, + uid: uid, + roomId: roomId, isNew: isNew, parsedMessage: parsed }; @@ -339,161 +211,107 @@ var db = require('./database'), }); }; - Messaging.isNewSet = function(fromuid, touid, mid, callback) { - var uids = sortUids(fromuid, touid), - setKey = 'messages:uid:' + uids[0] + ':to:' + uids[1]; + Messaging.isNewSet = function(uid, roomId, timestamp, callback) { + var setKey = 'uid:' + uid + ':chat:room:' + roomId + ':mids'; async.waterfall([ - async.apply(db.sortedSetRank, setKey, mid), - function(index, next) { - if (index > 0) { - db.getSortedSetRange(setKey, index-1, index, next); - } else { - next(null, true); - } - }, - function(mids, next) { - if (typeof mids !== 'boolean' && mids && mids.length) { - db.getObjects(['message:' + mids[0], 'message:' + mids[1]], next); - } else { - next(null, mids); - } + function(next) { + db.getSortedSetRevRangeWithScores(setKey, 0, 0, next); }, function(messages, next) { - if (typeof messages !== 'boolean' && messages && messages.length) { - next(null, parseInt(messages[1].timestamp, 10) > parseInt(messages[0].timestamp, 10) + (1000*60*5)); + if (messages && messages.length) { + next(null, parseInt(timestamp, 10) > parseInt(messages[0].score, 10) + (1000 * 60 * 5)); } else { - next(null, messages); + next(null, true); } } ], callback); }; - Messaging.updateChatTime = function(uid, toUid, callback) { - callback = callback || function() {}; - db.sortedSetAdd('uid:' + uid + ':chats', Date.now(), toUid, callback); - }; Messaging.getRecentChats = function(uid, start, stop, callback) { - db.getSortedSetRevRange('uid:' + uid + ':chats', start, stop, function(err, uids) { + db.getSortedSetRevRange('uid:' + uid + ':chat:rooms', start, stop, function(err, roomIds) { if (err) { return callback(err); } async.parallel({ unread: function(next) { - db.isSortedSetMembers('uid:' + uid + ':chats:unread', uids, next); + db.isSortedSetMembers('uid:' + uid + ':chat:rooms:unread', roomIds, next); }, users: function(next) { - user.getUsersFields(uids, ['uid', 'username', 'picture', 'status', 'lastonline'] , next); + async.map(roomIds, function(roomId, next) { + db.getSortedSetRevRange('chat:room:' + roomId + ':uids', 0, 3, function(err, uids) { + if (err) { + return next(err); + } + uids = uids.filter(function(value) { + return value && parseInt(value, 10) !== parseInt(uid, 10); + }); + user.getUsersFields(uids, ['uid', 'username', 'picture', 'status', 'lastonline'] , next); + }); + }, next); }, teasers: function(next) { - async.map(uids, function(fromuid, next) { - Messaging.getMessages({ - fromuid: fromuid, - touid: uid, - isNew: false, - count: 1, - markRead: false - }, function(err, teaser) { - teaser = teaser[0]; - teaser.content = S(teaser.content).stripTags().decodeHTMLEntities().s; - next(err, teaser); - }); + async.map(roomIds, function(roomId, next) { + Messaging.getTeaser(uid, roomId, next); }, next); } }, function(err, results) { if (err) { return callback(err); } - - results.users.forEach(function(userData, index) { - if (userData && parseInt(userData.uid, 10)) { - userData.unread = results.unread[index]; - userData.status = user.getStatus(userData); - userData.teaser = results.teasers[index]; - } - }); - - results.users = results.users.filter(function(user) { - return user && parseInt(user.uid, 10); + var rooms = results.users.map(function(users, index) { + var data = { + users: users, + unread: results.unread[index], + roomId: roomIds[index], + teaser: results.teasers[index] + }; + data.users.forEach(function(userData) { + if (userData && parseInt(userData.uid, 10)) { + userData.status = user.getStatus(userData); + } + }); + data.users = data.users.filter(function(user) { + return user && parseInt(user.uid, 10); + }); + data.lastUser = data.users[0]; + data.usernames = data.users.map(function(user) { + return user.username; + }).join(', '); + return data; }); - callback(null, {users: results.users, nextStart: stop + 1}); + callback(null, {rooms: rooms, nextStart: stop + 1}); }); }); }; - Messaging.getUnreadCount = function(uid, callback) { - db.sortedSetCard('uid:' + uid + ':chats:unread', callback); - }; - - Messaging.pushUnreadCount = function(uid) { - Messaging.getUnreadCount(uid, function(err, unreadCount) { - if (err) { - return; - } - sockets.in('uid_' + uid).emit('event:unread.updateChatCount', unreadCount); - }); - }; - - Messaging.markRead = function(uid, toUid, callback) { - db.sortedSetRemove('uid:' + uid + ':chats:unread', toUid, callback); - }; - - Messaging.markUnread = function(uid, toUid, callback) { + Messaging.getTeaser = function (uid, roomId, callback) { async.waterfall([ function (next) { - user.exists(toUid, next); + db.getSortedSetRevRange('uid:' + uid + ':chat:room:' + roomId + ':mids', 0, 0, next); }, - function (exists, next) { - if (!exists) { - return next(new Error('[[error:no-user]]')); + function (mids, next) { + if (!mids || !mids.length) { + return next(null, null); } - db.sortedSetAdd('uid:' + uid + ':chats:unread', Date.now(), toUid, next); + Messaging.getMessageFields(mids[0], ['content', 'timestamp'], next); + }, + function (teaser, next) { + if (teaser && teaser.content) { + teaser.content = S(teaser.content).stripTags().decodeHTMLEntities().s; + teaser.timestampISO = utils.toISOString(teaser.timestamp); + } + + next(null, teaser); } ], callback); }; - Messaging.notifyUser = function(fromuid, touid, messageObj) { - // Immediate notifications - // Recipient - Messaging.pushUnreadCount(touid); - sockets.in('uid_' + touid).emit('event:chats.receive', { - withUid: fromuid, - message: messageObj, - self: 0 - }); - // Sender - Messaging.pushUnreadCount(fromuid); - sockets.in('uid_' + fromuid).emit('event:chats.receive', { - withUid: touid, - message: messageObj, - self: 1 - }); - - // Delayed notifications - var queueObj = Messaging.notifyQueue[fromuid + ':' + touid]; - if (queueObj) { - queueObj.message.content += '\n' + messageObj.content; - clearTimeout(queueObj.timeout); - } else { - queueObj = Messaging.notifyQueue[fromuid + ':' + touid] = { - message: messageObj - }; - } - - queueObj.timeout = setTimeout(function() { - sendNotifications(fromuid, touid, queueObj.message, function(err) { - if (!err) { - delete Messaging.notifyQueue[fromuid + ':' + touid]; - } - }); - }, 1000*60); // wait 60s before sending - }; - - Messaging.canMessage = function(fromUid, toUid, callback) { - if (parseInt(meta.config.disableChat) === 1 || !fromUid || toUid === fromUid) { + Messaging.canMessageUser = function(uid, toUid, callback) { + if (parseInt(meta.config.disableChat) === 1 || !uid || uid === toUid) { return callback(null, false); } @@ -505,7 +323,7 @@ var db = require('./database'), if (!exists) { return callback(null, false); } - user.getUserFields(fromUid, ['banned', 'email:confirmed'], next); + user.getUserFields(uid, ['banned', 'email:confirmed'], next); }, function (userData, next) { if (parseInt(userData.banned, 10) === 1) { @@ -523,24 +341,31 @@ var db = require('./database'), return callback(null, true); } - user.isAdministrator(fromUid, next); + user.isAdministrator(uid, next); }, function(isAdmin, next) { if (isAdmin) { return callback(null, true); } - user.isFollowing(toUid, fromUid, next); + user.isFollowing(toUid, uid, next); } ], callback); + }; - Messaging.canEdit = function(messageId, uid, callback) { - if (parseInt(meta.config.disableChat) === 1) { + Messaging.canMessageRoom = function(uid, roomId, callback) { + if (parseInt(meta.config.disableChat) === 1 || !uid) { return callback(null, false); } async.waterfall([ function (next) { + Messaging.isUserInRoom(uid, roomId, next); + }, + function (inRoom, next) { + if (!inRoom) { + return callback(null, false); + } user.getUserFields(uid, ['banned', 'email:confirmed'], next); }, function (userData, next) { @@ -552,54 +377,10 @@ var db = require('./database'), return callback(null, false); } - Messaging.getMessageField(messageId, 'fromuid', next); - }, - function(fromUid, next) { - if (parseInt(fromUid, 10) === parseInt(uid, 10)) { - return callback(null, true); - } - - user.isAdministrator(uid, next); - }, - function(isAdmin, next) { - next(null, isAdmin); + next(null, true); } ], callback); }; - function sendNotifications(fromuid, touid, messageObj, callback) { - user.isOnline(touid, function(err, isOnline) { - if (err || isOnline) { - return callback(err); - } - - notifications.create({ - bodyShort: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', - bodyLong: messageObj.content, - nid: 'chat_' + fromuid + '_' + touid, - from: fromuid, - path: '/chats/' + messageObj.fromUser.username - }, function(err, notification) { - if (!err && notification) { - notifications.push(notification, [touid], callback); - } - }); - - user.getSettings(messageObj.toUser.uid, function(err, settings) { - if (settings.sendChatNotifications && !parseInt(meta.config.disableEmailSubscriptions, 10)) { - emailer.send('notif_chat', touid, { - subject: '[[email:notif.chat.subject, ' + messageObj.fromUser.username + ']]', - username: messageObj.toUser.username, - userslug: utils.slugify(messageObj.toUser.username), - summary: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', - message: messageObj, - site_title: meta.config.title || 'NodeBB', - url: nconf.get('url'), - fromUserslug: utils.slugify(messageObj.fromUser.username) - }); - } - }); - }); - } }(exports)); diff --git a/src/messaging/create.js b/src/messaging/create.js new file mode 100644 index 0000000000..d01eaf6292 --- /dev/null +++ b/src/messaging/create.js @@ -0,0 +1,118 @@ +'use strict'; + +var async = require('async'); + +var meta = require('../meta'); +var plugins = require('../plugins'); +var db = require('../database'); + + +module.exports = function(Messaging) { + + Messaging.sendMessage = function(uid, roomId, content, timestamp, callback) { + async.waterfall([ + function (next) { + Messaging.checkContent(content, next); + }, + function (next) { + Messaging.isUserInRoom(uid, roomId, next); + }, + function (inRoom, next) { + if (!inRoom) { + return next(new Error('[[error:not-allowed]]')); + } + + Messaging.addMessage(uid, roomId, content, timestamp, next); + } + ], callback); + }; + + Messaging.checkContent = function(content, callback) { + if (!content) { + return callback(new Error('[[error:invalid-chat-message]]')); + } + + if (content.length > (meta.config.maximumChatMessageLength || 1000)) { + return callback(new Error('[[error:chat-message-too-long]]')); + } + callback(); + }; + + Messaging.addMessage = function(fromuid, roomId, content, timestamp, callback) { + var mid; + var message; + var isNewSet; + + async.waterfall([ + function (next) { + Messaging.checkContent(content, next); + }, + function (next) { + db.incrObjectField('global', 'nextMid', next); + }, + function (_mid, next) { + mid = _mid; + message = { + content: content, + timestamp: timestamp, + fromuid: fromuid + }; + + plugins.fireHook('filter:messaging.save', message, next); + }, + function (message, next) { + db.setObject('message:' + mid, message, next); + }, + function (next) { + Messaging.isNewSet(fromuid, roomId, timestamp, next); + }, + function (_isNewSet, next) { + isNewSet = _isNewSet; + db.getSortedSetRange('chat:room:' + roomId + ':uids', 0, -1, next); + }, + function (uids, next) { + async.parallel([ + async.apply(Messaging.addRoomToUsers, roomId, uids, timestamp), + async.apply(Messaging.addMessageToUsers, roomId, uids, mid, timestamp), + async.apply(Messaging.markUnread, uids, roomId) + ], next); + }, + function (results, next) { + async.parallel({ + markRead: async.apply(Messaging.markRead, fromuid, roomId), + messages: async.apply(Messaging.getMessagesData, [mid], fromuid, roomId, true) + }, next); + }, + function (results, next) { + if (!results.messages || !results.messages[0]) { + return next(null, null); + } + + results.messages[0].newSet = isNewSet; + results.messages[0].mid = mid; + results.messages[0].roomId = roomId; + next(null, results.messages[0]); + } + ], callback); + }; + + Messaging.addRoomToUsers = function(roomId, uids, timestamp, callback) { + if (!uids.length) { + return callback(); + } + var keys = uids.map(function(uid) { + return 'uid:' + uid + ':chat:rooms'; + }); + db.sortedSetsAdd(keys, timestamp, roomId, callback); + }; + + Messaging.addMessageToUsers = function(roomId, uids, mid, timestamp, callback) { + if (!uids.length) { + return callback(); + } + var keys = uids.map(function(uid) { + return 'uid:' + uid + ':chat:room:' + roomId + ':mids'; + }); + db.sortedSetsAdd(keys, timestamp, mid, callback); + }; +}; \ No newline at end of file diff --git a/src/messaging/delete.js b/src/messaging/delete.js new file mode 100644 index 0000000000..518ab9fafb --- /dev/null +++ b/src/messaging/delete.js @@ -0,0 +1,27 @@ +'use strict'; + +var async = require('async'); +var db = require('../database'); + +module.exports = function(Messaging) { + + Messaging.deleteMessage = function(mid, roomId, callback) { + async.waterfall([ + function (next) { + Messaging.getUidsInRoom(roomId, 0, -1, next); + }, + function (uids, next) { + if (!uids.length) { + return next(); + } + var keys = uids.map(function(uid) { + return 'uid:' + uid + ':chat:room:' + roomId + 'mids'; + }); + db.sortedSetsRemove(keys, roomId, next); + }, + function(next) { + db.delete('message:' + mid, next); + } + ], callback); + }; +}; \ No newline at end of file diff --git a/src/messaging/edit.js b/src/messaging/edit.js new file mode 100644 index 0000000000..5b2472c4e4 --- /dev/null +++ b/src/messaging/edit.js @@ -0,0 +1,80 @@ +'use strict'; + +var async = require('async'); + +var meta = require('../meta'); +var user = require('../user'); + +var sockets = require('../socket.io'); + + +module.exports = function(Messaging) { + + Messaging.editMessage = function(uid, mid, roomId, content, callback) { + var uids; + async.waterfall([ + function(next) { + Messaging.getMessageField(mid, 'content', next); + }, + function (raw, next) { + if (raw === content) { + return callback(); + } + + Messaging.setMessageFields(mid, { + content: content, + edited: Date.now() + }, next); + }, + function (next) { + Messaging.getUidsInRoom(roomId, 0, -1, next); + }, + function (_uids, next) { + uids = _uids; + Messaging.getMessagesData([mid], uid, roomId, true, next); + }, + function (messages, next) { + uids.forEach(function(uid) { + sockets.in('uid_' + uid).emit('event:chats.edit', { + messages: messages + }); + }); + next(); + } + ], callback); + }; + + Messaging.canEdit = function(messageId, uid, callback) { + if (parseInt(meta.config.disableChat) === 1) { + return callback(null, false); + } + + async.waterfall([ + function (next) { + user.getUserFields(uid, ['banned', 'email:confirmed'], next); + }, + function (userData, next) { + if (parseInt(userData.banned, 10) === 1) { + return callback(null, false); + } + + if (parseInt(meta.config.requireEmailConfirmation, 10) === 1 && parseInt(userData['email:confirmed'], 10) !== 1) { + return callback(null, false); + } + + Messaging.getMessageField(messageId, 'fromuid', next); + }, + function(fromUid, next) { + if (parseInt(fromUid, 10) === parseInt(uid, 10)) { + return callback(null, true); + } + + user.isAdministrator(uid, next); + }, + function(isAdmin, next) { + next(null, isAdmin); + } + ], callback); + }; + +}; \ No newline at end of file diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js new file mode 100644 index 0000000000..9fca3c952b --- /dev/null +++ b/src/messaging/notifications.js @@ -0,0 +1,105 @@ +'use strict'; + +var async = require('async'); +var nconf = require('nconf'); + +var user = require('../user'); +var emailer = require('../emailer'); +var notifications = require('../notifications'); +var meta = require('../meta'); +var utils = require('../../public/src/utils'); +var sockets = require('../socket.io'); + +module.exports = function(Messaging) { + + Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser + + Messaging.notifyUsersInRoom = function(fromUid, roomId, messageObj) { + Messaging.getUidsInRoom(roomId, 0, -1, function(err, uids) { + if (err) { + return; + } + + var data = { + roomId: roomId, + fromUid: fromUid, + message: messageObj + }; + uids.forEach(function(uid) { + data.self = parseInt(uid, 10) === parseInt(fromUid) ? 1 : 0; + Messaging.pushUnreadCount(uid); + sockets.in('uid_' + uid).emit('event:chats.receive', data); + }); + + // Delayed notifications + var queueObj = Messaging.notifyQueue[fromUid + ':' + roomId]; + if (queueObj) { + queueObj.message.content += '\n' + messageObj.content; + clearTimeout(queueObj.timeout); + } else { + queueObj = Messaging.notifyQueue[fromUid + ':' + roomId] = { + message: messageObj + }; + } + + queueObj.timeout = setTimeout(function() { + sendNotifications(fromUid, uids, roomId, queueObj.message, function(err) { + if (!err) { + delete Messaging.notifyQueue[fromUid + ':' + roomId]; + } + }); + }, 1000*60); // wait 60s before sending + }); + }; + + function sendNotifications(fromuid, uids, roomId, messageObj, callback) { + user.isOnline(uids, function(err, isOnline) { + if (err) { + return callback(err); + } + + uids = uids.filter(function(uid, index) { + return isOnline[index] && parseInt(fromuid, 10) !== parseInt(uid, 10); + }); + + if (!uids.length) { + return callback(); + } + + notifications.create({ + bodyShort: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', + bodyLong: messageObj.content, + nid: 'chat_' + fromuid + '_' + roomId, + from: fromuid, + path: '/chats/' + messageObj.roomId + }, function(err, notification) { + if (!err && notification) { + notifications.push(notification, uids, callback); + } + }); + + if (parseInt(meta.config.disableEmailSubscriptions, 10) === 1) { + return callback(); + } + + user.getMultipleUserSettings(uids, function(err, userSettings) { + if (err) { + return callback(err); + } + userSettings = userSettings.filter(function(settings) { + return settings && settings.sendChatNotifications; + }); + async.each(userSettings, function(settings, next) { + emailer.send('notif_chat', settings.uid, { + subject: '[[email:notif.chat.subject, ' + messageObj.fromUser.username + ']]', + summary: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', + message: messageObj, + site_title: meta.config.title || 'NodeBB', + url: nconf.get('url'), + fromUserslug: utils.slugify(messageObj.fromUser.username) + }, next); + }, callback); + }); + }); + } +}; \ No newline at end of file diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js new file mode 100644 index 0000000000..5cc3459291 --- /dev/null +++ b/src/messaging/rooms.js @@ -0,0 +1,106 @@ +'use strict'; + +var async = require('async'); + +var db = require('../database'); +var user = require('../user'); + +module.exports = function(Messaging) { + + Messaging.newRoom = function(uid, toUids, callback) { + var roomId; + var now = Date.now(); + async.waterfall([ + function (next) { + db.incrObjectField('global', 'nextChatRoomId', next); + }, + function (_roomId, next) { + roomId = _roomId; + db.sortedSetAdd('chat:room:' + roomId + ':uids', now, uid, next); + }, + function (next) { + Messaging.addUsersToRoom(uid, toUids, roomId, next); + }, + function (next) { + Messaging.addRoomToUsers(roomId, [uid].concat(toUids), now, next); + }, + function (next) { + next(null, roomId); + } + ], callback); + }; + + Messaging.isUserInRoom = function(uid, roomId, callback) { + db.isSortedSetMember('chat:room:' + roomId + ':uids', uid, callback); + }; + + Messaging.roomExists = function(roomId, callback) { + db.exists('chat:room:' + roomId + ':uids', callback); + }; + + Messaging.isRoomOwner = function(uid, roomId, callback) { + db.getSortedSetRange('chat:room:' + roomId + ':uids', 0, 0, function(err, uids) { + if (err) { + return callback(err); + } + if (!Array.isArray(uids) || !uids.length) { + return callback(null, false); + } + callback(null, parseInt(uids[0], 10) === parseInt(uid, 10)); + }); + }; + + Messaging.addUsersToRoom = function(uid, uids, roomId, callback) { + async.waterfall([ + function (next) { + Messaging.isUserInRoom(uid, roomId, next); + }, + function (inRoom, next) { + if (!inRoom) { + return next(new Error('[[error:cant-add-users-to-chat-room]]')); + } + var now = Date.now(); + var timestamps = uids.map(function() { + return now; + }); + db.sortedSetAdd('chat:room:' + roomId + ':uids', timestamps, uids, next); + } + ], callback); + }; + + Messaging.removeUsersFromRoom = function(uid, uids, roomId, callback) { + async.waterfall([ + function (next) { + Messaging.isRoomOwner(uid, roomId, next); + }, + function (isOwner, next) { + if (!isOwner) { + return next(new Error('[[error:cant-add-users-to-chat-room]]')); + } + db.sortedSetRemove('chat:room:' + roomId + ':uids', uids, next); + }, + function (next) { + var keys = uids.map(function(uid) { + return 'uid:' + uid + ':chat:rooms'; + }); + db.sortedSetsRemove(keys, roomId, next); + } + ], callback); + }; + + Messaging.getUidsInRoom = function(roomId, start, stop, callback) { + db.getSortedSetRevRange('chat:room:' + roomId + ':uids', start, stop, callback); + }; + + Messaging.getUsersInRoom = function(roomId, start, stop, callback) { + async.waterfall([ + function (next) { + Messaging.getUidsInRoom(roomId, start, stop, next); + }, + function (uids, next) { + user.getUsersFields(uids, ['username', 'uid', 'picture', 'status'], next); + } + ], callback); + }; + +}; \ No newline at end of file diff --git a/src/messaging/unread.js b/src/messaging/unread.js new file mode 100644 index 0000000000..4bceaab3ce --- /dev/null +++ b/src/messaging/unread.js @@ -0,0 +1,45 @@ +'use strict'; + +var async = require('async'); + +var db = require('../database'); +var sockets = require('../socket.io'); + +module.exports = function(Messaging) { + + Messaging.getUnreadCount = function(uid, callback) { + db.sortedSetCard('uid:' + uid + ':chat:rooms:unread', callback); + }; + + Messaging.pushUnreadCount = function(uid) { + Messaging.getUnreadCount(uid, function(err, unreadCount) { + if (err) { + return; + } + sockets.in('uid_' + uid).emit('event:unread.updateChatCount', unreadCount); + }); + }; + + Messaging.markRead = function(uid, roomId, callback) { + db.sortedSetRemove('uid:' + uid + ':chat:rooms:unread', roomId, callback); + }; + + Messaging.markUnread = function(uids, roomId, callback) { + async.waterfall([ + function (next) { + Messaging.roomExists(roomId, next); + }, + function (exists, next) { + if (!exists) { + return next(new Error('[[error:chat-room-does-not-exist]]')); + } + var keys = uids.map(function(uid) { + return 'uid:' + uid + ':chat:rooms:unread'; + }); + + db.sortedSetsAdd(keys, Date.now(), roomId, next); + } + ], callback); + }; + +}; \ No newline at end of file diff --git a/src/routes/accounts.js b/src/routes/accounts.js index 2d492c6c78..e3a753c2fc 100644 --- a/src/routes/accounts.js +++ b/src/routes/accounts.js @@ -23,5 +23,5 @@ module.exports = function (app, middleware, controllers) { setupPageRoute(app, '/user/:userslug/settings', middleware, accountMiddlewares, controllers.accounts.settings.get); setupPageRoute(app, '/notifications', middleware, [middleware.authenticate], controllers.accounts.notifications.get); - setupPageRoute(app, '/chats/:userslug?', middleware, [middleware.authenticate], controllers.accounts.chats.get); + setupPageRoute(app, '/chats/:roomid?', middleware, [middleware.authenticate], controllers.accounts.chats.get); }; diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js index 0361560f01..2b4246b625 100644 --- a/src/socket.io/groups.js +++ b/src/socket.io/groups.js @@ -22,6 +22,10 @@ SocketGroups.join = function(socket, data, callback) { return callback(new Error('[[error:invalid-uid]]')); } + if (data.groupName === 'administrators' || groups.isPrivilegeGroup(data.groupName)) { + return callback(new Error('[[error:not-allowed]]')); + } + groups.exists(data.groupName, function(err, exists) { if (err || !exists) { return callback(err || new Error('[[error:no-group]]')); diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 42ac4092d2..3cd7ba3486 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -1,14 +1,13 @@ "use strict"; -var meta = require('../meta'), - Messaging = require('../messaging'), - utils = require('../../public/src/utils'), - - async = require('async'), - - server = require('./'), - - SocketModules = { +var async = require('async'); +var meta = require('../meta'); +var Messaging = require('../messaging'); +var utils = require('../../public/src/utils'); +var server = require('./'); +var user = require('../user'); + +var SocketModules = { chats: {}, sounds: {}, settings: {} @@ -17,36 +16,65 @@ var meta = require('../meta'), /* Chat */ SocketModules.chats.get = function(socket, data, callback) { - if(!data) { + if(!data || !data.roomId) { return callback(new Error('[[error:invalid-data]]')); } Messaging.getMessages({ - fromuid: socket.uid, - touid: data.touid, + uid: socket.uid, + roomId: data.roomId, since: data.since, isNew: false }, callback); - // Mark chat as read - Messaging.markRead(socket.uid, data.touid); + Messaging.markRead(socket.uid, data.roomId); }; SocketModules.chats.getRaw = function(socket, data, callback) { - if(!data || !data.hasOwnProperty('mid')) { + if (!data || !data.hasOwnProperty('mid')) { return callback(new Error('[[error:invalid-data]]')); } + async.waterfall([ + function (next) { + Messaging.isUserInRoom(socket.uid, data.roomId, next); + }, + function (inRoom, next) { + if (!inRoom) { + return next(new Error('[[error:not-allowed]]')); + } + Messaging.getMessageField(data.mid, 'content', next); + } + ], callback); +}; - Messaging.getMessageField(data.mid, 'content', callback); +SocketModules.chats.newRoom = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + var now = Date.now(); + // Websocket rate limiting + socket.lastChatMessageTime = socket.lastChatMessageTime || 0; + if (now - socket.lastChatMessageTime < 200) { + return callback(new Error('[[error:too-many-messages]]')); + } else { + socket.lastChatMessageTime = now; + } + + Messaging.canMessageUser(socket.uid, data.touid, function(err, allowed) { + if (err || !allowed) { + return callback(err || new Error('[[error:chat-restricted]]')); + } + + Messaging.newRoom(socket.uid, [data.touid], callback); + }); }; SocketModules.chats.send = function(socket, data, callback) { - if (!data) { + if (!data || !data.roomId) { return callback(new Error('[[error:invalid-data]]')); } - var now = Date.now(), - touid = parseInt(data.touid, 10); + var now = Date.now(); // Websocket rate limiting socket.lastChatMessageTime = socket.lastChatMessageTime || 0; @@ -56,57 +84,112 @@ SocketModules.chats.send = function(socket, data, callback) { socket.lastChatMessageTime = now; } - Messaging.canMessage(socket.uid, touid, function(err, allowed) { + Messaging.canMessageRoom(socket.uid, data.roomId, function(err, allowed) { if (err || !allowed) { return callback(err || new Error('[[error:chat-restricted]]')); } - Messaging.addMessage(socket.uid, touid, data.message, function(err, message) { + Messaging.sendMessage(socket.uid, data.roomId, data.message, now, function(err, message) { if (err) { return callback(err); } - Messaging.notifyUser(socket.uid, touid, message); + Messaging.notifyUsersInRoom(socket.uid, data.roomId, message); callback(); }); }); }; +SocketModules.chats.getUsersInRoom = function(socket, data, callback) { + if (!data || !data.roomId) { + return callback(new Error('[[error:invalid-data]]')); + } + + async.waterfall([ + function (next) { + Messaging.isUserInRoom(socket.uid, data.roomId, next); + }, + function (inRoom, next) { + if (!inRoom) { + return next(new Error('[[error:not-allowerd]]')); + } + Messaging.getUsersInRoom(data.roomId, 0, -1, next); + } + ], callback); +}; + +SocketModules.chats.addUserToRoom = function(socket, data, callback) { + if (!data || !data.roomId || !data.username) { + return callback(new Error('[[error:invalid-data]]')); + } + async.waterfall([ + function (next) { + user.getUidByUsername(data.username, next); + }, + function (uid, next) { + if (!uid) { + return next(new Error('[[error:no-user]]')); + } + Messaging.addUsersToRoom(socket.uid, [uid], data.roomId, next); + } + ], callback); +}; + +SocketModules.chats.removeUserFromRoom = function(socket, data, callback) { + if (!data || !data.roomId) { + return callback(new Error('[[error:invalid-data]]')); + } + async.waterfall([ + function (next) { + user.getUidByUsername(data.username, next); + }, + function (uid, next) { + if (!uid) { + return next(new Error('[[error:no-user]]')); + } + + Messaging.removeUsersFromRoom(socket.uid, [uid], data.roomId, next); + } + ], callback); +}; + SocketModules.chats.edit = function(socket, data, callback) { - if (!data) { + if (!data || !data.roomId) { return callback(new Error('[[error:invalid-data]]')); } Messaging.canEdit(data.mid, socket.uid, function(err, allowed) { - if (allowed) { - Messaging.editMessage(data.mid, data.message, callback); - } else { - return callback(new Error('[[error:cant-edit-chat-message]]')); + if (err || !allowed) { + return callback(err || new Error('[[error:cant-edit-chat-message]]')); } + + Messaging.editMessage(socket.uid, data.mid, data.roomId, data.message, callback); }); }; SocketModules.chats.delete = function(socket, data, callback) { - if (!data) { + if (!data || !data.roomId || !data.messageId) { return callback(new Error('[[error:invalid-data]]')); } Messaging.canEdit(data.messageId, socket.uid, function(err, allowed) { - if (allowed) { - Messaging.deleteMessage(data.messageId, callback); + if (err || !allowed) { + return callback(err || new Error('[[error:cant-delete-chat-message]]')); } + + Messaging.deleteMessage(data.messageId, data.roomId, callback); }); -} +}; -SocketModules.chats.canMessage = function(socket, toUid, callback) { - Messaging.canMessage(socket.uid, toUid, function(err, allowed) { +SocketModules.chats.canMessage = function(socket, roomId, callback) { + Messaging.canMessageRoom(socket.uid, roomId, function(err, allowed) { callback(!allowed ? new Error('[[error:chat-restricted]]') : undefined); }); }; -SocketModules.chats.markRead = function(socket, touid, callback) { - Messaging.markRead(socket.uid, touid, function(err) { +SocketModules.chats.markRead = function(socket, roomId, callback) { + Messaging.markRead(socket.uid, roomId, function(err) { if (err) { return callback(err); } @@ -125,10 +208,20 @@ SocketModules.chats.userStopTyping = function(socket, data, callback) { }; function sendTypingNotification(event, socket, data, callback) { - if (!socket.uid || !data) { + if (!socket.uid || !data || !data.roomId) { return; } - server.in('uid_' + data.touid).emit(event, data.fromUid); + + Messaging.getUidsInRoom(data.roomId, 0, -1, function(err, uids) { + if (err) { + return callback(err); + } + uids.forEach(function(uid) { + if (socket.uid !== parseInt(uid, 10)) { + server.in('uid_' + uid).emit(event, data.fromUid); + } + }); + }); } SocketModules.chats.getRecentChats = function(socket, data, callback) { diff --git a/src/upgrade.js b/src/upgrade.js index ecbfcfe6dd..4b2f78e56c 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -10,7 +10,7 @@ var db = require('./database'), schemaDate, thisSchemaDate, // IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema - latestSchema = Date.UTC(2015, 10, 6); + latestSchema = Date.UTC(2015, 11, 15); Upgrade.check = function(callback) { db.get('schemaDate', function(err, value) { @@ -122,7 +122,7 @@ Upgrade.upgrade = function(callback) { db.setObjectField('user:' + uid, 'picture', '', next); }, function (next) { - db.deleteObjectField('user:' + uid, 'gravatarpicture', next); + db.deleteObjectField('user:' + uid, 'gravatarpicture', next); } ], next); } else { @@ -143,8 +143,96 @@ Upgrade.upgrade = function(callback) { winston.info('[2015/11/06] Gravatar removal skipped'); next(); } - } + }, + function(next) { + thisSchemaDate = Date.UTC(2015, 11, 15); + + if (schemaDate < thisSchemaDate) { + updatesMade = true; + winston.info('[2015/12/15] Upgrading chats'); + + db.getObjectFields('global', ['nextMid', 'nextChatRoomId'], function(err, globalData) { + if (err) { + return next(err); + } + + var rooms = {}; + var roomId = globalData.nextChatRoomId || 1; + var currentMid = 1; + + async.whilst(function() { + return currentMid < globalData.nextMid; + }, function(next) { + db.getObject('message:' + currentMid, function(err, message) { + function addMessageToUids(roomId, callback) { + async.parallel([ + function(next) { + db.sortedSetAdd('uid:' + message.fromuid + ':chat:room:' + roomId + ':mids', msgTime, currentMid, next); + }, + function(next) { + db.sortedSetAdd('uid:' + message.touid + ':chat:room:' + roomId + ':mids', msgTime, currentMid, next); + } + ], callback); + } + + if (err || !message) { + winston.info('skipping chat message ', currentMid); + currentMid ++; + return next(err); + } + + var pairID = [parseInt(message.fromuid, 10), parseInt(message.touid, 10)].sort().join(':'); + var msgTime = parseInt(message.timestamp, 10); + + if (rooms[pairID]) { + winston.info('adding message ' + currentMid + ' to existing roomID ' + roomId); + addMessageToUids(rooms[pairID], function(err) { + if (err) { + return next(err); + } + currentMid ++; + next(); + }); + } else { + winston.info('adding message ' + currentMid + ' to new roomID ' + roomId); + async.parallel([ + function(next) { + db.sortedSetAdd('uid:' + message.fromuid + ':chat:rooms', msgTime, roomId, next); + }, + function(next) { + db.sortedSetAdd('uid:' + message.touid + ':chat:rooms', msgTime, roomId, next); + }, + function(next) { + db.sortedSetAdd('chat:room:' + roomId + ':uids', [msgTime, msgTime + 1], [message.fromuid, message.touid], next); + }, + function(next) { + addMessageToUids(roomId, next); + } + ], function(err) { + if (err) { + return next(err); + } + rooms[pairID] = roomId; + roomId ++; + currentMid ++; + db.setObjectField('global', 'nextChatRoomId', roomId, next); + }); + } + }); + }, function(err) { + if (err) { + return next(err); + } + winston.info('[2015/12/15] Chats upgrade done!'); + Upgrade.update(thisSchemaDate, next); + }); + }); + } else { + winston.info('[2015/12/15] Chats upgrade skipped!'); + next(); + } + } // Add new schema updates here // IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema IN LINE 24!!! ], function(err) { diff --git a/src/user.js b/src/user.js index 4b64b52dbd..7a9213a963 100644 --- a/src/user.js +++ b/src/user.js @@ -132,13 +132,27 @@ var async = require('async'), }; User.isOnline = function(uid, callback) { - db.sortedSetScore('users:online', uid, function(err, lastonline) { - if (err) { - return callback(err); - } - var isOnline = Date.now() - parseInt(lastonline, 10) < 300000; - callback(null, isOnline); - }); + if (Array.isArray(uid)) { + db.sortedSetScores('users:online', uid, function(err, lastonline) { + if (err) { + return callback(err); + } + var now = Date.now(); + var isOnline = uid.map(function(uid, index) { + return now - lastonline[index] < 300000; + }); + callback(null, isOnline); + }); + } else { + db.sortedSetScore('users:online', uid, function(err, lastonline) { + if (err) { + return callback(err); + } + var isOnline = Date.now() - parseInt(lastonline, 10) < 300000; + callback(null, isOnline); + }); + } + }; User.exists = function(uid, callback) { diff --git a/src/user/delete.js b/src/user/delete.js index 2fc444da42..5b822fcd7f 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -55,6 +55,9 @@ module.exports = function(User) { function (next) { deleteVotes(uid, next); }, + function (next) { + deleteChats(uid, next); + }, function (next) { async.parallel([ function(next) { @@ -97,6 +100,7 @@ module.exports = function(User) { 'uid:' + uid + ':favourites', 'uid:' + uid + ':followed_tids', 'user:' + uid + ':settings', 'uid:' + uid + ':topics', 'uid:' + uid + ':posts', 'uid:' + uid + ':chats', 'uid:' + uid + ':chats:unread', + 'uid:' + uid + ':chat:rooms', 'uid:' + uid + ':chat:rooms:unread', 'uid:' + uid + ':upvote', 'uid:' + uid + ':downvote', 'uid:' + uid + ':ignored:cids', 'uid:' + uid + ':flag:pids' ]; @@ -145,6 +149,29 @@ module.exports = function(User) { }); } + function deleteChats(uid, callback) { + async.waterfall([ + function (next) { + db.getSortedSetRange('uid:' + uid + ':chat:rooms', 0, -1, next); + }, + function (roomIds, next) { + var userKeys = roomIds.map(function(roomId) { + return 'uid:' + uid + ':chat:room:' + roomId + ':mids'; + }); + var roomKeys = roomIds.map(function(roomId) { + return 'chat:room:' + roomId + ':uids'; + }); + + async.parallel([ + async.apply(db.sortedSetsRemove, roomKeys, uid), + async.apply(db.deleteAll, userKeys) + ], next); + } + ], function(err) { + callback(err); + }); + } + function deleteUserIps(uid, callback) { async.waterfall([ function (next) { diff --git a/src/user/notifications.js b/src/user/notifications.js index 2d08b83e33..f40143a28d 100644 --- a/src/user/notifications.js +++ b/src/user/notifications.js @@ -125,9 +125,7 @@ var async = require('async'), notification.path = pidToPaths[notification.pid] || notification.path || ''; - if (notification.nid.startsWith('chat')) { - notification.path = '/chats/' + notification.user.userslug; - } else if (notification.nid.startsWith('follow')) { + if (notification.nid.startsWith('follow')) { notification.path = '/user/' + notification.user.userslug; } diff --git a/tests/messaging.js b/tests/messaging.js index a709640430..ae1db0952a 100644 --- a/tests/messaging.js +++ b/tests/messaging.js @@ -27,14 +27,14 @@ describe('Messaging Library', function() { describe('.canMessage()', function() { it('should not error out', function(done) { - Messaging.canMessage(testUids[1], testUids[2], function(err, allowed) { + Messaging.canMessageUser(testUids[1], testUids[2], function(err, allowed) { assert.ifError(err); done(); }); }); it('should allow messages to be sent to an unrestricted user', function(done) { - Messaging.canMessage(testUids[1], testUids[2], function(err, allowed) { + Messaging.canMessageUser(testUids[1], testUids[2], function(err, allowed) { assert.strictEqual(allowed, true, 'should be true, received ' + allowed); done(); }); @@ -42,7 +42,7 @@ describe('Messaging Library', function() { it('should NOT allow messages to be sent to a restricted user', function(done) { User.setSetting(testUids[1], 'restrictChat', '1', function() { - Messaging.canMessage(testUids[2], testUids[1], function(err, allowed) { + Messaging.canMessageUser(testUids[2], testUids[1], function(err, allowed) { assert.strictEqual(allowed, false, 'should be false, received ' + allowed); done(); }); @@ -50,7 +50,7 @@ describe('Messaging Library', function() { }); it('should always allow admins through', function(done) { - Messaging.canMessage(testUids[0], testUids[1], function(err, allowed) { + Messaging.canMessageUser(testUids[0], testUids[1], function(err, allowed) { assert.strictEqual(allowed, true, 'should be true, received ' + allowed); done(); }); @@ -58,7 +58,7 @@ describe('Messaging Library', function() { it('should allow messages to be sent to a restricted user if restricted user follows sender', function(done) { User.follow(testUids[1], testUids[2], function() { - Messaging.canMessage(testUids[2], testUids[1], function(err, allowed) { + Messaging.canMessageUser(testUids[2], testUids[1], function(err, allowed) { assert.strictEqual(allowed, true, 'should be true, received ' + allowed); done(); });