Chat message soft deletion -- closes #6181

Squashed commit of the following:

commit f84c06bdcc45f24ef7ffde6a8f33b48d8f97fc36
Author: Julian Lam <julian@nodebb.org>
Date:   Mon Dec 18 14:42:47 2017 -0500

    added restore handler for chat messages

commit 725cd370c6ea1e8f4a28298350f3dc024d4e668e
Author: Julian Lam <julian@nodebb.org>
Date:   Mon Dec 18 14:23:52 2017 -0500

    backend logic and testing complete for deletion and restoration of chat messages

commit 072da758319cc93fa4c6f8bc0d672a1b716dc06e
Author: Julian Lam <julian@nodebb.org>
Date:   Mon Dec 18 13:52:35 2017 -0500

    changing message delete logic to not remove mids, but to filter when retrieving

commit 68bf373305ab82737658a7c31dc5549af4d6d69f
Author: Julian Lam <julian@nodebb.org>
Date:   Mon Dec 18 12:37:58 2017 -0500

    logic to handle deletion of a deleted chat message -- added some failing tests

commit 6899d0d234fa752e227188aa69cfcabd0d0500cc
Author: Julian Lam <julian@nodebb.org>
Date:   Mon Dec 18 11:35:36 2017 -0500

    chat message deletion logic
v1.18.x
Julian Lam 7 years ago
parent 541aa7fbc6
commit 82a95a03be

@ -140,6 +140,8 @@
"cant-delete-chat-message": "You are not allowed to delete this message", "cant-delete-chat-message": "You are not allowed to delete this message",
"chat-edit-duration-expired": "You are only allowed to edit chat messages for %1 second(s) after posting", "chat-edit-duration-expired": "You are only allowed to edit chat messages for %1 second(s) after posting",
"chat-delete-duration-expired": "You are only allowed to delete chat messages for %1 second(s) after posting", "chat-delete-duration-expired": "You are only allowed to delete chat messages for %1 second(s) after posting",
"chat-deleted-already": "This chat message has already been deleted.",
"chat-restored'already": "This chat message has already been restored.",
"already-voting-for-this-post": "You have already voted for this post.", "already-voting-for-this-post": "You have already voted for this post.",
"reputation-system-disabled": "Reputation system is disabled.", "reputation-system-disabled": "Reputation system is disabled.",

@ -71,7 +71,7 @@ define('forum/chats', [
}); });
}); });
Chats.addEditDeleteHandler(components.get('chat/messages'), ajaxify.data.roomId); Chats.addActionHandlers(components.get('chat/messages'), ajaxify.data.roomId);
Chats.addRenameHandler(ajaxify.data.roomId, $('[component="chat/room/name"]')); Chats.addRenameHandler(ajaxify.data.roomId, $('[component="chat/room/name"]'));
Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content')); Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content'));
@ -123,14 +123,25 @@ define('forum/chats', [
}); });
}; };
Chats.addEditDeleteHandler = function (element, roomId) { Chats.addActionHandlers = function (element, roomId) {
element.on('click', '[data-action="edit"]', function () { element.on('click', '[data-action]', function () {
var messageId = $(this).parents('[data-mid]').attr('data-mid'); var messageId = $(this).parents('[data-mid]').attr('data-mid');
var inputEl = $('[data-roomid="' + roomId + '"] [component="chat/input"]'); var action = this.getAttribute('data-action');
messages.prepEdit(inputEl, messageId, roomId);
}).on('click', '[data-action="delete"]', function () { switch (action) {
var messageId = $(this).parents('[data-mid]').attr('data-mid'); case 'edit':
messages.delete(messageId, roomId); var inputEl = $('[data-roomid="' + roomId + '"] [component="chat/input"]');
messages.prepEdit(inputEl, messageId, roomId);
break;
case 'delete':
messages.delete(messageId, roomId);
break;
case 'restore':
messages.restore(messageId, roomId);
break;
}
}); });
}; };

@ -146,13 +146,24 @@ define('forum/chats/messages', ['components', 'sounds', 'translator', 'benchpres
return app.alertError(err.message); return app.alertError(err.message);
} }
components.get('chat/message', messageId).slideUp('slow', function () { components.get('chat/message', messageId).toggleClass('deleted', true);
$(this).remove();
});
}); });
}); });
}); });
}; };
messages.restore = function (messageId, roomId) {
socket.emit('modules.chats.restore', {
messageId: messageId,
roomId: roomId,
}, function (err) {
if (err) {
return app.alertError(err.message);
}
components.get('chat/message', messageId).toggleClass('deleted', false);
});
};
return messages; return messages;
}); });

@ -56,6 +56,16 @@ Messaging.getMessages = function (params, callback) {
messageData.forEach(function (messageData) { messageData.forEach(function (messageData) {
messageData.index = indices[messageData.messageId.toString()]; messageData.index = indices[messageData.messageId.toString()];
}); });
// Filter out deleted messages unless you're the sender of said message
messageData = messageData.filter(function (messageData) {
if (messageData.deleted && parseInt(messageData.fromuid, 10) !== parseInt(params.uid, 10)) {
return false;
}
return true;
});
next(null, messageData); next(null, messageData);
}, },
], callback); ], callback);

@ -58,6 +58,7 @@ module.exports = function (Messaging) {
timestamp: timestamp, timestamp: timestamp,
fromuid: fromuid, fromuid: fromuid,
roomId: roomId, roomId: roomId,
deleted: 0,
}; };
plugins.fireHook('filter:messaging.save', message, next); plugins.fireHook('filter:messaging.save', message, next);

@ -67,6 +67,8 @@ module.exports = function (Messaging) {
if (message.hasOwnProperty('edited')) { if (message.hasOwnProperty('edited')) {
message.editedISO = new Date(parseInt(message.edited, 10)).toISOString(); message.editedISO = new Date(parseInt(message.edited, 10)).toISOString();
} }
message.deleted = !!parseInt(message.deleted, 10);
}); });
async.map(messages, function (message, next) { async.map(messages, function (message, next) {

@ -1,25 +1,30 @@
'use strict'; 'use strict';
var async = require('async'); var async = require('async');
var db = require('../database');
module.exports = function (Messaging) { module.exports = function (Messaging) {
Messaging.deleteMessage = function (mid, roomId, callback) { Messaging.deleteMessage = function (mid, roomId, callback) {
async.waterfall([ async.waterfall([
function (next) { async.apply(Messaging.getMessageField, mid, 'deleted'),
Messaging.getUidsInRoom(roomId, 0, -1, next); function (deleted, next) {
}, if (parseInt(deleted, 10)) {
function (uids, next) { return next(new Error('[[error:chat-deleted-already]]'));
if (!uids.length) {
return next();
} }
var keys = uids.map(function (uid) {
return 'uid:' + uid + ':chat:room:' + roomId + ':mids'; Messaging.setMessageField(mid, 'deleted', 1, next);
});
db.sortedSetsRemove(keys, mid, next);
}, },
function (next) { ], callback);
db.delete('message:' + mid, next); };
Messaging.restoreMessage = function (mid, roomId, callback) {
async.waterfall([
async.apply(Messaging.getMessageField, mid, 'deleted'),
function (deleted, next) {
if (!parseInt(deleted, 10)) {
return next(new Error('[[error:chat-restored-already]]'));
}
Messaging.setMessageField(mid, 'deleted', 0, next);
}, },
], callback); ], callback);
}; };

@ -267,6 +267,21 @@ SocketModules.chats.delete = function (socket, data, callback) {
], callback); ], callback);
}; };
SocketModules.chats.restore = function (socket, data, callback) {
if (!data || !data.roomId || !data.messageId) {
return callback(new Error('[[error:invalid-data]]'));
}
async.waterfall([
function (next) {
Messaging.canDelete(data.messageId, socket.uid, next);
},
function (next) {
Messaging.restoreMessage(data.messageId, data.roomId, next);
},
], callback);
};
SocketModules.chats.canMessage = function (socket, roomId, callback) { SocketModules.chats.canMessage = function (socket, roomId, callback) {
Messaging.canMessageRoom(socket.uid, roomId, callback); Messaging.canMessageRoom(socket.uid, roomId, callback);
}; };

@ -14,8 +14,8 @@ var helpers = require('./helpers');
var socketModules = require('../src/socket.io/modules'); var socketModules = require('../src/socket.io/modules');
describe('Messaging Library', function () { describe('Messaging Library', function () {
var fooUid; var fooUid; // the admin
var bazUid; var bazUid; // the user with chat restriction enabled
var herpUid; var herpUid;
var roomId; var roomId;
@ -552,21 +552,71 @@ describe('Messaging Library', function () {
}); });
}); });
it('should mark the message as deleted', function (done) {
socketModules.chats.delete({ uid: fooUid }, { messageId: mid, roomId: roomId }, function (err) {
assert.ifError(err);
db.getObjectField('message:' + mid, 'deleted', function (err, value) {
assert.ifError(err);
assert.strictEqual(1, parseInt(value, 10));
done();
});
});
});
it('should show deleted message to original users', function (done) {
socketModules.chats.getMessages({ uid: fooUid }, { uid: fooUid, roomId: roomId, start: 0 }, function (err, messages) {
assert.ifError(err);
// Reduce messages to their mids
var mids = messages.reduce(function (mids, cur) {
mids.push(cur.messageId);
return mids;
}, []);
assert(mids.includes(mid));
done();
});
});
it('should not show deleted message to other users', function (done) {
socketModules.chats.getMessages({ uid: herpUid }, { uid: herpUid, roomId: roomId, start: 0 }, function (err, messages) {
assert.ifError(err);
it('should delete message', function (done) { // Reduce messages to their mids
var mids = messages.reduce(function (mids, cur) {
mids.push(cur.messageId);
return mids;
}, []);
assert(!mids.includes(mid));
done();
});
});
it('should error out if a message is deleted again', function (done) {
socketModules.chats.delete({ uid: fooUid }, { messageId: mid, roomId: roomId }, function (err) { socketModules.chats.delete({ uid: fooUid }, { messageId: mid, roomId: roomId }, function (err) {
assert.strictEqual('[[error:chat-deleted-already]]', err.message);
done();
});
});
it('should restore the message', function (done) {
socketModules.chats.restore({ uid: fooUid }, { messageId: mid, roomId: roomId }, function (err) {
assert.ifError(err); assert.ifError(err);
db.exists('message:' + mid, function (err, exists) { db.getObjectField('message:' + mid, 'deleted', function (err, value) {
assert.ifError(err); assert.ifError(err);
assert(!exists); assert.strictEqual(0, parseInt(value, 10));
db.isSortedSetMember('uid:' + fooUid + ':chat:room:' + roomId + ':mids', mid, function (err, isMember) { done();
assert.ifError(err);
assert(!isMember);
done();
});
}); });
}); });
}); });
it('should error out if a message is restored again', function (done) {
socketModules.chats.restore({ uid: fooUid }, { messageId: mid, roomId: roomId }, function (err) {
assert.strictEqual('[[error:chat-restored-already]]', err.message);
done();
});
});
}); });
describe('controller', function () { describe('controller', function () {

Loading…
Cancel
Save