diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index d65ba7cbba..0fd767d049 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -10,6 +10,7 @@ "chat.no_active": "You have no active chats.", "chat.user_typing": "%1 is typing ...", "chat.user_has_messaged_you": "%1 has messaged you.", + "chat.replying-to": "Replying to %1", "chat.see_all": "All chats", "chat.mark_all_read": "Mark all read", "chat.no-messages": "Please select a recipient to view chat message history", diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 7dab41cd22..d92d27a532 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -72,16 +72,18 @@ define('forum/chats', [ Chats.addEventListeners = function () { const { roomId } = ajaxify.data; const mainWrapper = $('[component="chat/main-wrapper"]'); + const chatMessageContent = $('[component="chat/message/content"]'); const chatControls = components.get('chat/controls'); Chats.addSendHandlers(roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]')); Chats.addPopoutHandler(); Chats.addActionHandlers(components.get('chat/messages'), roomId); - Chats.addManageHandler(roomId, chatControls.find('[data-action="members"]')); + Chats.addManageHandler(roomId, chatControls.find('[data-action="manage"]')); Chats.addRenameHandler(roomId, chatControls.find('[data-action="rename"]')); Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]')); Chats.addDeleteHandler(roomId, chatControls.find('[data-action="delete"]')); - Chats.addScrollHandler(roomId, ajaxify.data.uid, $('[component="chat/message/content"]')); - Chats.addScrollBottomHandler($('[component="chat/message/content"]')); + Chats.addScrollHandler(roomId, ajaxify.data.uid, chatMessageContent); + Chats.addScrollBottomHandler(chatMessageContent); + Chats.addParentHandler(chatMessageContent); Chats.addCharactersLeftHandler(mainWrapper); Chats.addTextareaResizeHandler(mainWrapper); Chats.addIPHandler(mainWrapper); @@ -98,10 +100,10 @@ define('forum/chats', [ Chats.switchChat(); }); userList.init(roomId, mainWrapper); - messageSearch.init(roomId); + Chats.addNotificationSettingHandler(roomId, mainWrapper); + messageSearch.init(roomId, mainWrapper); Chats.addPublicRoomSortHandler(); Chats.addTooltipHandler(); - Chats.addNotificationSettingHandler(); }; Chats.addPublicRoomSortHandler = function () { @@ -141,20 +143,32 @@ define('forum/chats', [ }); }; - Chats.addNotificationSettingHandler = function () { - const notifSettingEl = $('[component="chat/notification/setting"]'); + Chats.addNotificationSettingHandler = function (roomId, containerEl) { + const notifSettingEl = containerEl.find('[component="chat/notification/setting"]'); notifSettingEl.find('[data-value]').on('click', async function () { notifSettingEl.find('i.fa-check').addClass('hidden'); const $this = $(this); $this.find('i.fa-check').removeClass('hidden'); - $('[component="chat/notification/setting/icon"]').attr('class', `fa ${$this.attr('data-icon')}`); + notifSettingEl.find('[component="chat/notification/setting/icon"]').attr('class', `fa ${$this.attr('data-icon')}`); await socket.emit('modules.chats.setNotificationSetting', { - roomId: ajaxify.data.roomId, + roomId: roomId, value: $this.attr('data-value'), }); }); }; + Chats.addParentHandler = function (chatContent) { + chatContent.on('click', '[component="chat/message/parent"]', function () { + const parentEl = $(this); + parentEl.find('[component="chat/message/parent/content"]').toggleClass('line-clamp-1'); + parentEl.find('.chat-timestamp').toggleClass('hidden'); + parentEl.toggleClass('flex-column').toggleClass('flex-row'); + if (chatContent.length && messages.isAtBottom(chatContent)) { + messages.scrollToBottom(chatContent); + } + }); + }; + Chats.addUploadHandler = function (options) { uploadHelpers.init({ @@ -285,14 +299,15 @@ define('forum/chats', [ const action = this.getAttribute('data-action'); switch (action) { - case 'edit': { + case 'reply': + messages.prepReplyTo(msgEl, roomId); + break; + case 'edit': messages.prepEdit(msgEl, messageId, roomId); break; - } case 'delete': messages.delete(messageId, roomId); break; - case 'restore': messages.restore(messageId, roomId); break; diff --git a/public/src/client/chats/message-search.js b/public/src/client/chats/message-search.js index b46cd651dc..d6490c8b3e 100644 --- a/public/src/client/chats/message-search.js +++ b/public/src/client/chats/message-search.js @@ -11,17 +11,17 @@ define('forum/chats/message-search', [ let chatContent; let clearEl; let toggleEl; - let containerEl; - messageSearch.init = function (_roomId) { + let searchContainerEl; + messageSearch.init = function (_roomId, containerEl) { roomId = _roomId; - resultListEl = $('[component="chat/message/search/results"]'); - chatContent = $('[component="chat/message/content"]'); - clearEl = $('[component="chat/room/search/clear"]'); - containerEl = $('[component="chat/room/search/container"]'); - toggleEl = $('[component="chat/room/search/toggle"'); + resultListEl = containerEl.find('[component="chat/message/search/results"]'); + chatContent = containerEl.find('[component="chat/message/content"]'); + clearEl = containerEl.find('[component="chat/room/search/clear"]'); + searchContainerEl = containerEl.find('[component="chat/room/search/container"]'); + toggleEl = containerEl.find('[component="chat/room/search/toggle"'); - searchInputEl = $('[component="chat/room/search"]'); + searchInputEl = containerEl.find('[component="chat/room/search"]'); searchInputEl.on('keyup', utils.debounce(doSearch, 250)) .on('focus', () => { if (searchInputEl.val()) { @@ -29,14 +29,14 @@ define('forum/chats/message-search', [ } }); - $('[component="chat/input"]').on('focus', () => { + containerEl.find('[component="chat/input"]').on('focus', () => { resultListEl.addClass('hidden'); chatContent.removeClass('hidden'); }); clearEl.on('click', clearInputAndResults); toggleEl.on('click', () => { - containerEl.removeClass('hidden'); + searchContainerEl.removeClass('hidden'); toggleEl.addClass('hidden'); searchInputEl.trigger('focus'); }); @@ -52,7 +52,7 @@ define('forum/chats/message-search', [ removeResults(); resultListEl.addClass('hidden'); clearEl.addClass('hidden'); - containerEl.addClass('hidden'); + searchContainerEl.addClass('hidden'); chatContent.removeClass('hidden'); toggleEl.removeClass('hidden'); } diff --git a/public/src/client/chats/messages.js b/public/src/client/chats/messages.js index 041cee4585..724a23bbf0 100644 --- a/public/src/client/chats/messages.js +++ b/public/src/client/chats/messages.js @@ -21,9 +21,13 @@ define('forum/chats/messages', [ messages.updateTextAreaHeight(chatContent); const payload = { roomId, message }; ({ roomId, message } = await hooks.fire('filter:chat.send', payload)); - - api.post(`/chats/${roomId}`, { message }).then(() => { + const replyToEl = inputEl.parents('[component="chat/composer"]') + .find('[component="chat/composer/replying-to"]'); + const toMid = replyToEl.attr('data-tomid'); + api.post(`/chats/${roomId}`, { message, toMid: toMid }).then(() => { hooks.fire('action:chat.sent', { roomId, message }); + replyToEl.addClass('hidden'); + replyToEl.attr('data-tomid', ''); }).catch((err) => { inputEl.val(message).trigger('input'); messages.updateRemainingLength(inputEl.parent()); @@ -76,7 +80,7 @@ define('forum/chats/messages', [ const lastSpeaker = parseInt(lastMsgEl.attr('data-uid'), 10); const lasttimestamp = parseInt(lastMsgEl.attr('data-timestamp'), 10); if (!Array.isArray(data)) { - data.newSet = lastSpeaker !== parseInt(data.fromuid, 10) || + data.newSet = data.toMid || lastSpeaker !== parseInt(data.fromuid, 10) || parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3); } @@ -156,6 +160,28 @@ define('forum/chats/messages', [ .toggleClass('hidden', isAtBottom); }; + messages.prepReplyTo = async function (msgEl, roomId) { + const chatMessages = msgEl.parents(`[component="chat/messages"][data-roomid="${roomId}"]`); + const chatContent = chatMessages.find('[component="chat/message/content"]'); + const composerEl = chatMessages.find('[component="chat/composer"]'); + const mid = msgEl.attr('data-mid'); + const replyToEl = composerEl.find('[component="chat/composer/replying-to"]'); + replyToEl.attr('data-tomid', mid) + .find('[component="chat/composer/replying-to-text"]') + .translateText(`[[modules:chat.replying-to, ${msgEl.attr('data-username')}]]`); + replyToEl.removeClass('hidden'); + replyToEl.find('[component="chat/composer/replying-to-cancel"]').off('click') + .on('click', () => { + replyToEl.attr('data-tomid', ''); + replyToEl.addClass('hidden'); + }); + + if (chatContent.length && messages.isAtBottom(chatContent)) { + messages.scrollToBottom(chatContent); + } + composerEl.find('[component="chat/input"]').trigger('focus'); + }; + messages.prepEdit = async function (msgEl, mid, roomId) { const raw = await socket.emit('modules.chats.getRaw', { mid: mid, roomId: roomId }); const editEl = await app.parseAndTranslate('partials/chats/edit-message', { diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 33b3e9a16a..a932943e0a 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -190,7 +190,7 @@ define('chat', [ newMessage = data.self === 0; } data.message.self = data.self; - data.message.timestamp = Math.min(Date.now(), data.message.timetamp); + data.message.timestamp = Math.min(Date.now(), data.message.timestamp); data.message.timestampISO = utils.toISOString(data.message.timestamp); addMessageToModal(data); } @@ -203,13 +203,13 @@ define('chat', [ require(['forum/chats/messages'], function (ChatsMessages) { // don't add if already added if (!modal.find('[data-mid="' + data.message.messageId + '"]').length) { - ChatsMessages.appendChatMessage(modal.find('.chat-content'), data.message); + ChatsMessages.appendChatMessage(modal.find('[component="chat/message/content"]'), data.message); } if (modal.is(':visible')) { taskbar.updateActive(modal.attr('data-uuid')); - if (ChatsMessages.isAtBottom(modal.find('.chat-content'))) { - ChatsMessages.scrollToBottom(modal.find('.chat-content')); + if (ChatsMessages.isAtBottom(modal.find('[component="chat/message/content"]'))) { + ChatsMessages.scrollToBottom(modal.find('[component="chat/message/content"]')); } } else if (!ajaxify.data.template.chats) { module.toggleNew(modal.attr('data-uuid'), true, true); @@ -254,17 +254,18 @@ define('chat', [ module.createModal = function (data, callback) { callback = callback || function () {}; require([ - 'scrollStop', 'forum/chats', 'forum/chats/messages', - ], function (scrollStop, Chats, ChatsMessages) { + 'scrollStop', 'forum/chats', 'forum/chats/messages', 'forum/chats/message-search', + ], function (scrollStop, Chats, ChatsMessages, messageSearch) { app.parseAndTranslate('chat', data, function (chatModal) { - if (module.modalExists(data.roomId)) { + const roomId = data.roomId; + if (module.modalExists(roomId)) { return callback(module.getModal(data.roomId)); } const uuid = utils.generateUUID(); let dragged = false; - chatModal.attr('id', 'chat-modal-' + data.roomId); - chatModal.attr('data-roomid', data.roomId); + chatModal.attr('id', 'chat-modal-' + roomId); + chatModal.attr('data-roomid', roomId); chatModal.attr('intervalId', 0); chatModal.attr('data-uuid', uuid); chatModal.css('position', 'fixed'); @@ -313,7 +314,7 @@ define('chat', [ components.get('chat/input').val(text); }); - ajaxify.go('user/' + app.user.userslug + '/chats/' + chatModal.attr('data-roomid')); + ajaxify.go(`user/${app.user.userslug}/chats/${roomId}`); module.close(uuid); } @@ -340,23 +341,23 @@ define('chat', [ chatModal.on('mousemove keypress click', function () { if (newMessage) { - api.del(`/chats/${data.roomId}/state`, {}); + api.del(`/chats/${roomId}/state`, {}); newMessage = false; } }); - Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), data.roomId); - Chats.addRenameHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="rename"]'), data.roomName); - Chats.addLeaveHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="leave"]')); - Chats.addDeleteHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="delete"]')); - Chats.addSendHandlers(chatModal.attr('data-roomid'), chatModal.find('.chat-input'), chatModal.find('[data-action="send"]')); - Chats.addManageHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]')); + Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), roomId); + Chats.addRenameHandler(roomId, chatModal.find('[data-action="rename"]'), data.roomName); + Chats.addLeaveHandler(roomId, chatModal.find('[data-action="leave"]')); + Chats.addDeleteHandler(roomId, chatModal.find('[data-action="delete"]')); + Chats.addSendHandlers(roomId, chatModal.find('.chat-input'), chatModal.find('[data-action="send"]')); + Chats.addManageHandler(roomId, chatModal.find('[data-action="manage"]')); - Chats.createAutoComplete(chatModal.attr('data-roomid'), chatModal.find('[component="chat/input"]')); - - Chats.addScrollHandler(chatModal.attr('data-roomid'), data.uid, chatModal.find('.chat-content')); - Chats.addScrollBottomHandler(chatModal.find('.chat-content')); + Chats.createAutoComplete(roomId, chatModal.find('[component="chat/input"]')); + Chats.addScrollHandler(roomId, data.uid, chatModal.find('[component="chat/message/content"]')); + Chats.addScrollBottomHandler(chatModal.find('[component="chat/message/content"]')); + Chats.addParentHandler(chatModal.find('[component="chat/message/content"]')); Chats.addCharactersLeftHandler(chatModal); Chats.addTextareaResizeHandler(chatModal); Chats.addIPHandler(chatModal); @@ -370,6 +371,8 @@ define('chat', [ }); ChatsMessages.addSocketListeners(); + messageSearch.init(roomId, chatModal); + Chats.addNotificationSettingHandler(roomId, chatModal); taskbar.push('chat', chatModal.attr('data-uuid'), { title: '[[modules:chat.chatting_with]] ' + (data.roomName || (data.users.length ? data.users[0].username : '')), diff --git a/src/api/chats.js b/src/api/chats.js index 16a54de74d..dc46ec522d 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -87,6 +87,7 @@ chatsAPI.post = async (caller, data) => { uid: caller.uid, roomId: data.roomId, content: data.message, + toMid: data.toMid, timestamp: Date.now(), ip: caller.ip, }); diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index 2266795eec..f6b9378836 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -33,6 +33,7 @@ Chats.get = async (req, res) => { Chats.post = async (req, res) => { const messageObj = await api.chats.post(req, { message: req.body.message, + toMid: req.body.toMid, roomId: req.params.roomId, }); diff --git a/src/messaging/create.js b/src/messaging/create.js index 872f22613f..81da98d0d6 100644 --- a/src/messaging/create.js +++ b/src/messaging/create.js @@ -6,6 +6,7 @@ const meta = require('../meta'); const plugins = require('../plugins'); const db = require('../database'); const user = require('../user'); +const utils = require('../utils'); module.exports = function (Messaging) { Messaging.sendMessage = async (data) => { @@ -41,6 +42,9 @@ module.exports = function (Messaging) { if (!roomData) { throw new Error('[[error:no-room]]'); } + if (data.toMid && !utils.isNumber(data.toMid)) { + throw new Error('[[error:invalid-mid]]'); + } const mid = await db.incrObjectField('global', 'nextMid'); const timestamp = data.timestamp || Date.now(); let message = { @@ -50,7 +54,9 @@ module.exports = function (Messaging) { fromuid: uid, roomId: roomId, }; - + if (data.toMid) { + message.toMid = data.toMid; + } if (data.system) { message.system = data.system; } @@ -69,6 +75,9 @@ module.exports = function (Messaging) { db.sortedSetAdd('messages:mid', timestamp, mid), db.incrObjectField('global', 'messageCount'), ]; + if (data.toMid) { + tasks.push(db.sortedSetAdd(`mid:${data.toMid}:replies`, timestamp, mid)); + } if (roomData.public) { tasks.push( db.sortedSetAdd('chat:rooms:public:lastpost', timestamp, roomId) diff --git a/src/messaging/data.js b/src/messaging/data.js index 3542de799f..81d41e047a 100644 --- a/src/messaging/data.js +++ b/src/messaging/data.js @@ -1,5 +1,6 @@ 'use strict'; +const _ = require('lodash'); const validator = require('validator'); const db = require('../database'); @@ -73,16 +74,7 @@ module.exports = function (Messaging) { message.roomId = String(message.roomId || roomId); }); - messages = await Promise.all(messages.map(async (message) => { - if (message.system) { - message.content = validator.escape(String(message.content)); - return message; - } - - const result = await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew); - message.content = result; - return message; - })); + await parseMessages(messages, uid, roomId, isNew); if (messages.length > 1) { // Add a spacer in between messages with time gaps between them @@ -96,7 +88,7 @@ module.exports = function (Messaging) { message.newSet = true; } else if (index > 0 && messages[index - 1].system) { message.newSet = true; - } else if (index === 0) { + } else if (index === 0 || message.toMid) { message.newSet = true; } @@ -111,7 +103,7 @@ module.exports = function (Messaging) { const fields = await Messaging.getMessageFields(mid, ['fromuid', 'timestamp']); if ((messages[0].timestamp > fields.timestamp + Messaging.newMessageCutoff) || (messages[0].fromuid !== fields.fromuid) || - messages[0].system) { + messages[0].system || messages[0].toMid) { // If it's been 5 minutes, this is a new set of messages messages[0].newSet = true; } @@ -120,6 +112,8 @@ module.exports = function (Messaging) { } } + await addParentMessages(messages, uid, roomId); + const data = await plugins.hooks.fire('filter:messaging.getMessages', { messages: messages, uid: uid, @@ -130,6 +124,60 @@ module.exports = function (Messaging) { return data && data.messages; }; + + async function addParentMessages(messages, uid, roomId) { + let parentMids = messages.map(msg => (msg && msg.hasOwnProperty('toMid') ? parseInt(msg.toMid, 10) : null)).filter(Boolean); + + if (!parentMids.length) { + return; + } + parentMids = _.uniq(parentMids); + const parentMessages = await Messaging.getMessagesFields(parentMids, [ + 'fromuid', 'content', 'timestamp', + ]); + const parentUids = _.uniq(parentMessages.map(msg => msg && msg.fromuid)); + const usersMap = _.zipObject( + parentUids, + await user.getUsersFields(parentUids, ['uid', 'username', 'userslug', 'picture']) + ); + + await Promise.all(parentMessages.map(async (parentMsg) => { + const foundMsg = messages.find(msg => parseInt(msg.mid, 10) === parseInt(parentMsg.mid, 10)); + if (foundMsg) { + parentMsg.content = foundMsg.content; + return; + } + parentMsg.content = await parseMessage(parentMsg, uid, roomId, false); + })); + + const parents = {}; + parentMessages.forEach((msg, i) => { + if (usersMap[msg.fromuid]) { + msg.user = usersMap[msg.fromuid]; + parents[parentMids[i]] = msg; + } + }); + + messages.forEach((msg) => { + if (parents[msg.toMid]) { + msg.parent = parents[msg.toMid]; + msg.parent.mid = msg.toMid; + } + }); + } + + async function parseMessages(messages, uid, roomId, isNew) { + await Promise.all(messages.map(async (message) => { + message.content = await parseMessage(message, uid, roomId, isNew); + })); + } + async function parseMessage(message, uid, roomId, isNew) { + if (message.system) { + return validator.escape(String(message.content)); + } + + return await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew); + } }; async function modifyMessage(message, fields, mid) { diff --git a/src/topics/posts.js b/src/topics/posts.js index 438844d753..1d1b95af2b 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -189,14 +189,18 @@ module.exports = function (Topics) { const usersMap = _.zipObject(parentUids, userData); const parents = {}; parentPosts.forEach((post, i) => { - parents[parentPids[i]] = { - username: usersMap[post.uid].username, - displayname: usersMap[post.uid].displayname, - }; + if (usersMap[post.uid]) { + parents[parentPids[i]] = { + username: usersMap[post.uid].username, + displayname: usersMap[post.uid].displayname, + }; + } }); postData.forEach((post) => { - post.parent = parents[post.toPid]; + if (parents[post.toPid]) { + post.parent = parents[post.toPid]; + } }); };