diff --git a/package.json b/package.json index 86cb942c62..da7883cacc 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "nodebb-plugin-spam-be-gone": "0.4.5", "nodebb-rewards-essentials": "0.0.6", "nodebb-theme-lavender": "3.0.2", - "nodebb-theme-persona": "4.0.40", - "nodebb-theme-vanilla": "5.0.15", + "nodebb-theme-persona": "4.0.41", + "nodebb-theme-vanilla": "5.0.16", "nodebb-widget-essentials": "2.0.5", "nodemailer": "0.7.1", "npm": "^2.1.4", diff --git a/public/language/en_GB/error.json b/public/language/en_GB/error.json index 4159d211d8..4e00dd3470 100644 --- a/public/language/en_GB/error.json +++ b/public/language/en_GB/error.json @@ -101,6 +101,7 @@ "too-many-messages": "You have sent too many messages, please wait awhile.", "invalid-chat-message": "Invalid chat message", "chat-message-too-long": "Chat message is too long", + "cant-edit-chat-message": "You are not allowed to edit this message", "reputation-system-disabled": "Reputation system is disabled.", "downvoting-disabled": "Downvoting is disabled", diff --git a/public/language/en_GB/modules.json b/public/language/en_GB/modules.json index 6bb97b68a4..5dc56fa8c2 100644 --- a/public/language/en_GB/modules.json +++ b/public/language/en_GB/modules.json @@ -15,6 +15,7 @@ "chat.seven_days": "7 Days", "chat.thirty_days": "30 Days", "chat.three_months": "3 Months", + "chat.delete_message_confirm": "Are you sure you wish to delete this message?", "composer.compose": "Compose", "composer.show_preview": "Show Preview", diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 437c97978c..0531c40de9 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -63,6 +63,17 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }); }); + components.get('chat/messages') + .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); + }) + .on('click', '[data-action="delete"]', function() { + var messageId = $(this).parents('[data-mid]').attr('data-mid'); + Chats.delete(messageId); + }); + $('.recent-chats').on('scroll', function() { var $this = $(this); var bottom = ($this[0].scrollHeight - $this.height()) * 0.9; @@ -95,6 +106,49 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', $('[component="chat/input"]').focus(); }); + Mousetrap.bind('up', function(e) { + if (e.target === components.get('chat/input').get(0)) { + // Retrieve message id from messages list + var message = components.get('chat/messages').find('.chat-message[data-self="1"]').last(); + var lastMid = message.attr('data-mid'); + var inputEl = components.get('chat/input'); + + Chats.prepEdit(inputEl, lastMid); + } + }); + }; + + Chats.prepEdit = function(inputEl, messageId) { + socket.emit('modules.chats.getRaw', { mid: messageId }, function(err, raw) { + // 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 + // message, instead of posting a new one. + inputEl.attr('data-mid', messageId).addClass('editing'); + inputEl.val(raw); + } + }); + }; + + Chats.delete = function(messageId) { + 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(); + }); + }); + } + }); + }); }; Chats.addSinceHandler = function(toUid, chatContentEl, sinceEl) { @@ -253,6 +307,22 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', socket.on('event:user_status_change', function(data) { app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); }); + + 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); + if (body.length) { + body.replaceWith(html); + components.get('chat/message', message.messageId).find('.timeago').timeago(); + } + }); + }); + }); }; Chats.resizeMainWindow = function() { @@ -278,7 +348,9 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', }; Chats.sendMessage = function(toUid, inputEl) { - var msg = inputEl.val(); + var msg = inputEl.val(), + mid = inputEl.attr('data-mid'); + if (msg.length > config.maximumChatMessageLength) { return app.alertError('[[error:chat-message-too-long]]'); } @@ -288,20 +360,35 @@ define('forum/chats', ['components', 'string', 'sounds', 'forum/infinitescroll', } inputEl.val(''); - socket.emit('modules.chats.send', { - touid: toUid, - message: msg - }, function(err) { - if (err) { - if (err.message === '[[error:email-not-confirmed-chat]]') { - return app.showEmailConfirmWarning(err); + inputEl.removeAttr('data-mid'); + + if (!mid) { + socket.emit('modules.chats.send', { + touid: toUid, + message: msg + }, function(err) { + if (err) { + if (err.message === '[[error:email-not-confirmed-chat]]') { + return app.showEmailConfirmWarning(err); + } + return app.alertError(err.message); } - return app.alertError(err.message); - } - sounds.play('chat-outgoing'); - Chats.notifyTyping(toUid, false); - }); + sounds.play('chat-outgoing'); + Chats.notifyTyping(toUid, false); + }); + } else { + socket.emit('modules.chats.edit', { + mid: mid, + message: msg + }, function(err) { + if (err) { + return app.alertError(err.message); + } + + Chats.notifyTyping(toUid, false); + }); + } }; Chats.scrollToBottom = function(containerEl) { diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index a9363836cd..27d7b9548e 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -19,6 +19,10 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra }); socket.on('event:chats.receive', function(data) { + if (ajaxify.currentPage.match(/^chats/)) { + return; + } + var username = data.message.fromUser.username; var isSelf = parseInt(data.message.fromUser.uid, 10) === parseInt(app.user.uid, 10); data.message.self = data.self; @@ -252,6 +256,17 @@ define('chat', ['components', 'taskbar', 'string', 'sounds', 'forum/chats', 'tra } }); + chatModal.find('[component="chat/messages"]') + .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); + }) + .on('click', '[data-action="delete"]', function() { + var messageId = $(this).parents('[data-mid]').attr('data-mid'); + Chats.delete(messageId); + }); + Chats.addSinceHandler(chatModal.attr('touid'), chatModal.find('.chat-content'), chatModal.find('[data-since]')); Chats.addSendHandlers(chatModal.attr('touid'), chatModal.find('#chat-message-input'), chatModal.find('#chat-message-send-btn')); diff --git a/public/src/modules/components.js b/public/src/modules/components.js index da03bacd16..f4de069e01 100644 --- a/public/src/modules/components.js +++ b/public/src/modules/components.js @@ -35,6 +35,13 @@ define('components', function() { 'categories/category': function(name, value) { return $('[component="categories/category"][data-' + name + '="' + value + '"]'); + }, + + 'chat/message': function(messageId) { + return $('[component="chat/message"][data-mid="' + messageId + '"]'); + }, + 'chat/message/body': function(messageId) { + return $('[component="chat/message"][data-mid="' + messageId + '"] [component="chat/message/body"]'); } }; diff --git a/src/messaging.js b/src/messaging.js index e0a39eac59..7d3a89316e 100644 --- a/src/messaging.js +++ b/src/messaging.js @@ -106,6 +106,81 @@ var db = require('./database'), }); }; + 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]); + }); + }; + + Messaging.getMessageFields = function(mid, fields, callback) { + db.getObjectFields('message:' + mid, fields, callback); + }; + + Messaging.setMessageField = function(mid, field, content, callback) { + db.setObjectField('message:' + mid, field, content, callback); + }; + + Messaging.setMessageFields = function(mid, data, callback) { + db.setObject('message:' + mid, data, callback); + }; + Messaging.getMessages = function(params, callback) { var fromuid = params.fromuid, touid = params.touid, @@ -160,7 +235,12 @@ var db = require('./database'), async.waterfall([ async.apply(db.getObjects, keys), function(messages, next) { - messages = messages.filter(Boolean); + 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]; @@ -169,6 +249,10 @@ var db = require('./database'), message.self = self ? 1 : 0; message.newSet = false; + if (message.hasOwnProperty('edited')) { + message.editedISO = new Date(parseInt(message.edited, 10)).toISOString(); + } + Messaging.parse(message.content, message.fromuid, fromuid, userData[1], userData[0], isNew, function(result) { message.content = result; message.cleanedContent = S(result).stripTags().decodeHTMLEntities().s; @@ -177,21 +261,50 @@ var db = require('./database'), }, next); }, function(messages, next) { - // 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; - } - - return message; - }); + 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; + } + + return message; + }); - next(undefined, messages); + 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) { + db.getSortedSetRange(key, index-1, index-1, next); + }, + function(mid, next) { + Messaging.getMessageFields(mid, ['fromuid', 'timestamp'], next); + } + ], function(err, fields) { + if (err) { + return next(err); + } + + 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); }); @@ -277,7 +390,7 @@ var db = require('./database'), count: 1, markRead: false }, function(err, teaser) { - var teaser = teaser[0]; + teaser = teaser[0]; teaser.content = S(teaser.content).stripTags().decodeHTMLEntities().s; next(err, teaser); }); @@ -415,6 +528,39 @@ var db = require('./database'), ], 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); + }; + function sendNotifications(fromuid, touid, messageObj, callback) { user.isOnline(touid, function(err, isOnline) { if (err || isOnline) { diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 08b8a4a26a..7b6ed82778 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -29,6 +29,14 @@ SocketModules.chats.get = function(socket, data, callback) { }, callback); }; +SocketModules.chats.getRaw = function(socket, data, callback) { + if(!data || !data.hasOwnProperty('mid')) { + return callback(new Error('[[error:invalid-data]]')); + } + + Messaging.getMessageField(data.mid, 'content', callback); +}; + SocketModules.chats.send = function(socket, data, callback) { if (!data) { return callback(new Error('[[error:invalid-data]]')); @@ -62,6 +70,32 @@ SocketModules.chats.send = function(socket, data, callback) { }); }; +SocketModules.chats.edit = function(socket, data, callback) { + if (!data) { + 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]]')); + } + }); +}; + +SocketModules.chats.delete = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + Messaging.canEdit(data.messageId, socket.uid, function(err, allowed) { + if (allowed) { + Messaging.deleteMessage(data.messageId, callback); + } + }); +} + SocketModules.chats.canMessage = function(socket, toUid, callback) { Messaging.canMessage(socket.uid, toUid, function(err, allowed) { callback(!allowed ? new Error('[[error:chat-restricted]]') : undefined);