diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 40b8747097..517873ca6c 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -30,6 +30,10 @@ "chat.delete_message_confirm": "Are you sure you wish to delete this message?", "chat.retrieving-users": "Retrieving users...", "chat.view-users-list": "View users list", + "chat.pinned-messages": "Pinned Messages", + "chat.no-pinned-messages": "There are no pinned messages", + "chat.pin-message": "Pin Message", + "chat.unpin-message": "Unpin Message", "chat.public-rooms": "Public Rooms (%1)", "chat.private-rooms": "Private Rooms (%1)", "chat.create-room": "Create Chat Room", diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index e12e1ce2db..a9a546e779 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -184,6 +184,8 @@ paths: $ref: 'write/chats/roomId/messages.yaml' /chats/{roomId}/messages/{mid}: $ref: 'write/chats/roomId/messages/mid.yaml' + /chats/{roomId}/messages/{mid}/pin: + $ref: 'write/chats/roomId/messages/mid/pin.yaml' /flags/: $ref: 'write/flags.yaml' /flags/{flagId}: diff --git a/public/openapi/write/chats/roomId/messages/mid/pin.yaml b/public/openapi/write/chats/roomId/messages/mid/pin.yaml new file mode 100644 index 0000000000..56a85c0d73 --- /dev/null +++ b/public/openapi/write/chats/roomId/messages/mid/pin.yaml @@ -0,0 +1,66 @@ +put: + tags: + - chats + summary: pin a chat message + description: This operation pins an existing chat message in a chat room + parameters: + - in: path + name: roomId + schema: + type: string + required: true + description: a valid chat room id + example: 1 + - in: path + name: mid + schema: + type: string + required: true + description: a valid chat message id + example: 1 + responses: + '200': + description: Chat message successfully pinned + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} +delete: + tags: + - chats + summary: unpin a chat message + description: This operation unpins a chat message in a room + parameters: + - in: path + name: roomId + schema: + type: string + required: true + description: a valid chat room id + example: 1 + - in: path + name: mid + schema: + type: string + required: true + description: a valid chat message id + example: 1 + responses: + '200': + description: Chat message successfully unpinned + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/src/client/chats.js b/public/src/client/chats.js index b020d3ecb8..25b39f8915 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -10,6 +10,7 @@ define('forum/chats', [ 'forum/chats/messages', 'forum/chats/user-list', 'forum/chats/message-search', + 'forum/chats/pinned-messages', 'composer/autocomplete', 'hooks', 'bootbox', @@ -19,8 +20,9 @@ define('forum/chats', [ 'uploadHelpers', ], function ( components, mousetrap, recentChats, create, - manage, messages, userList, messageSearch, autocomplete, - hooks, bootbox, alerts, chatModule, api, uploadHelpers + manage, messages, userList, messageSearch, pinnedMessages, + autocomplete, hooks, bootbox, alerts, chatModule, api, + uploadHelpers ) { const Chats = { initialised: false, @@ -66,6 +68,7 @@ define('forum/chats', [ messages.wrapImagesInLinks(changeContentEl); messages.scrollToBottomAfterImageLoad(changeContentEl); create.init(); + pinnedMessages.init($('[component="chat/main-wrapper"]')); hooks.fire('action:chat.loaded', $('.chats-full')); }; @@ -77,7 +80,7 @@ define('forum/chats', [ 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.addActionHandlers(components.get('chat/message/window'), roomId); Chats.addManageHandler(roomId, chatControls.find('[data-action="manage"]')); Chats.addRenameHandler(roomId, chatControls.find('[data-action="rename"]')); Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]')); @@ -152,6 +155,7 @@ define('forum/chats', [ placement: 'top', container: '#content', animation: false, + trigger: 'hover', }); }; @@ -310,10 +314,10 @@ define('forum/chats', [ const msgEl = $(this).parents('[data-mid]'); const messageId = msgEl.attr('data-mid'); const action = this.getAttribute('data-action'); - + $(this).tooltip('dispose'); switch (action) { case 'reply': - messages.prepReplyTo(msgEl, roomId); + messages.prepReplyTo(msgEl, element); break; case 'edit': messages.prepEdit(msgEl, messageId, roomId); @@ -324,6 +328,12 @@ define('forum/chats', [ case 'restore': messages.restore(messageId, roomId); break; + case 'pin': + pinnedMessages.pin(messageId, roomId); + break; + case 'unpin': + pinnedMessages.unpin(messageId, roomId); + break; } }); }; diff --git a/public/src/client/chats/messages.js b/public/src/client/chats/messages.js index dd6476990b..88e03bf4bc 100644 --- a/public/src/client/chats/messages.js +++ b/public/src/client/chats/messages.js @@ -168,10 +168,9 @@ 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"]'); + messages.prepReplyTo = async function (msgEl, chatMessageWindow) { + const chatContent = chatMessageWindow.find('[component="chat/message/content"]'); + const composerEl = chatMessageWindow.find('[component="chat/composer"]'); const mid = msgEl.attr('data-mid'); const replyToEl = composerEl.find('[component="chat/composer/replying-to"]'); replyToEl.attr('data-tomid', mid) diff --git a/public/src/client/chats/pinned-messages.js b/public/src/client/chats/pinned-messages.js new file mode 100644 index 0000000000..57281036b2 --- /dev/null +++ b/public/src/client/chats/pinned-messages.js @@ -0,0 +1,86 @@ +'use strict'; + + +define('forum/chats/pinned-messages', ['api', 'alerts'], function (api, alerts) { + const pinnedMessages = {}; + let container; + pinnedMessages.init = function (_container) { + container = _container; + $('[component="chat/pinned/messages/btn"]').on('click', async () => { + const pinnedMessagesContainer = container.find('[component="chat/messages/pinned/container"]'); + if (!pinnedMessagesContainer.hasClass('hidden')) { + return pinnedMessagesContainer.addClass('hidden'); + } + const userListEl = container.find('[component="chat/user/list"]'); + userListEl.addClass('hidden'); + await pinnedMessages.refreshList(); + pinnedMessagesContainer.removeClass('hidden'); + }); + + handleInfiniteScroll(container); + }; + + function handleInfiniteScroll(container) { + const listEl = container.find('[component="chat/messages/pinned"]'); + listEl.on('scroll', utils.debounce(async () => { + const bottom = (listEl[0].scrollHeight - listEl.height()) * 0.85; + if (listEl.scrollTop() > bottom) { + const lastIndex = listEl.find('[data-index]').last().attr('data-index'); + const data = await loadData(parseInt(lastIndex, 10) + 1); + if (data && data.length) { + const html = await parseMessages(data); + container.find('[component="chat/messages/pinned"]').append(html); + } + } + }, 200)); + } + + pinnedMessages.refreshList = async function () { + const data = await loadData(0); + + if (!data.length) { + container.find('[component="chat/messages/pinned/empty"]').removeClass('hidden'); + container.find('[component="chat/messages/pinned"]').html(''); + return; + } + container.find('[component="chat/messages/pinned/empty"]').addClass('hidden'); + const html = await parseMessages(data); + container.find('[component="chat/messages/pinned"]').html(html); + html.find('.timeago').timeago(); + }; + + async function parseMessages(data) { + return await app.parseAndTranslate('partials/chats/pinned-messages', 'messages', { + isOwner: ajaxify.data.isOwner, + isAdminOrGlobalMod: ajaxify.data.isAdminOrGlobalMod, + messages: data, + }); + } + + async function loadData(start) { + const data = await socket.emit('modules.chats.loadPinnedMessages', { + roomId: ajaxify.data.roomId, + start: start, + }); + return data; + } + + pinnedMessages.pin = function (mid, roomId) { + api.put(`/chats/${roomId}/messages/${mid}/pin`, {}).then(() => { + $(`[component="chat/message"][data-mid="${mid}"]`).toggleClass('pinned', true); + pinnedMessages.refreshList(); + }).catch(alerts.error); + }; + + pinnedMessages.unpin = function (mid, roomId) { + api.del(`/chats/${roomId}/messages/${mid}/pin`, {}).then(() => { + $(`[component="chat/message"][data-mid="${mid}"]`).toggleClass('pinned', false); + container.find(`[component="chat/messages/pinned"] [data-mid="${mid}"]`).remove(); + if (!container.find(`[component="chat/messages/pinned"] [data-mid]`).length) { + container.find('[component="chat/messages/pinned/empty"]').removeClass('hidden'); + } + }).catch(alerts.error); + }; + + return pinnedMessages; +}); diff --git a/public/src/client/chats/user-list.js b/public/src/client/chats/user-list.js index 2b936c0698..a4bd77cf65 100644 --- a/public/src/client/chats/user-list.js +++ b/public/src/client/chats/user-list.js @@ -11,11 +11,13 @@ define('forum/chats/user-list', ['api'], function (api) { if (!userListEl.length) { return; } + const pinnedMessageListEl = container.find('[component="chat/messages/pinned/container"]'); container.find('[component="chat/user/list/btn"]').on('click', () => { userListEl.toggleClass('hidden'); if (userListEl.hasClass('hidden')) { stopUpdating(); } else { + pinnedMessageListEl.addClass('hidden'); startUpdating(roomId, userListEl); } }); @@ -29,6 +31,9 @@ define('forum/chats/user-list', ['api'], function (api) { }; function startUpdating(roomId, userListEl) { + if (updateInterval) { + clearInterval(updateInterval); + } updateInterval = setInterval(() => { updateUserList(roomId, userListEl); }, 5000); diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index ac8c830133..b64e7d144f 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -356,7 +356,7 @@ define('chat', [ } }); - Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), roomId); + Chats.addActionHandlers(chatModal.find('[component="chat/message/window"]'), roomId); Chats.addRenameHandler(roomId, chatModal.find('[data-action="rename"]')); Chats.addLeaveHandler(roomId, chatModal.find('[data-action="leave"]')); Chats.addDeleteHandler(roomId, chatModal.find('[data-action="delete"]')); diff --git a/src/api/chats.js b/src/api/chats.js index 3b40f92eba..281109f514 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -272,3 +272,13 @@ chatsAPI.restoreMessage = async (caller, { mid }) => { await messaging.canDelete(mid, caller.uid); await messaging.restoreMessage(mid, caller.uid); }; + +chatsAPI.pinMessage = async (caller, { roomId, mid }) => { + await messaging.canPin(roomId, caller.uid); + await messaging.pinMessage(mid, roomId); +}; + +chatsAPI.unpinMessage = async (caller, { roomId, mid }) => { + await messaging.canPin(roomId, caller.uid); + await messaging.unpinMessage(mid, roomId); +}; diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index f6b9378836..ce624ae30e 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -142,3 +142,17 @@ Chats.messages.restore = async (req, res) => { helpers.formatApiResponse(200, res); }; + +Chats.messages.pin = async (req, res) => { + const { mid, roomId } = req.params; + await api.chats.pinMessage(req, { mid, roomId }); + + helpers.formatApiResponse(200, res); +}; + +Chats.messages.unpin = async (req, res) => { + const { mid, roomId } = req.params; + await api.chats.unpinMessage(req, { mid, roomId }); + + helpers.formatApiResponse(200, res); +}; diff --git a/src/messaging/data.js b/src/messaging/data.js index 0ccaf1aec1..e6466f579c 100644 --- a/src/messaging/data.js +++ b/src/messaging/data.js @@ -46,21 +46,17 @@ module.exports = function (Messaging) { Messaging.getMessagesData = async (mids, uid, roomId, isNew) => { let messages = await Messaging.getMessagesFields(mids, []); - messages = await user.blocks.filter(uid, 'fromuid', messages); messages = messages .map((msg, idx) => { if (msg) { msg.messageId = parseInt(mids[idx], 10); msg.ip = undefined; msg.isOwner = msg.fromuid === parseInt(uid, 10); - if (msg.deleted && !msg.isOwner) { - msg.content = `

[[modules:chat.message-deleted]]

`; - } } return msg; }) .filter(Boolean); - + messages = await user.blocks.filter(uid, 'fromuid', messages); const users = await user.getUsersFields( messages.map(msg => msg && msg.fromuid), ['uid', 'username', 'userslug', 'picture', 'status', 'banned'] @@ -175,8 +171,12 @@ module.exports = function (Messaging) { } async function parseMessages(messages, uid, roomId, isNew) { - await Promise.all(messages.map(async (message) => { - message.content = await parseMessage(message, uid, roomId, isNew); + await Promise.all(messages.map(async (msg) => { + if (msg.deleted && !msg.isOwner) { + msg.content = `

[[modules:chat.message-deleted]]

`; + return; + } + msg.content = await parseMessage(msg, uid, roomId, isNew); })); } async function parseMessage(message, uid, roomId, isNew) { diff --git a/src/messaging/edit.js b/src/messaging/edit.js index 5bf76b65f8..62cf93de3e 100644 --- a/src/messaging/edit.js +++ b/src/messaging/edit.js @@ -90,4 +90,16 @@ module.exports = function (Messaging) { Messaging.canEdit = async (messageId, uid) => await canEditDelete(messageId, uid, 'edit'); Messaging.canDelete = async (messageId, uid) => await canEditDelete(messageId, uid, 'delete'); + + Messaging.canPin = async (roomId, uid) => { + const [isAdmin, isGlobalMod, inRoom, isRoomOwner] = await Promise.all([ + user.isAdministrator(uid), + user.isGlobalModerator(uid), + Messaging.isUserInRoom(uid, roomId), + Messaging.isRoomOwner(uid, roomId), + ]); + if (!isAdmin && !isGlobalMod && (!inRoom || !isRoomOwner)) { + throw new Error('[[error:no-privileges]]'); + } + }; }; diff --git a/src/messaging/index.js b/src/messaging/index.js index e3cf3deab2..c012f858bc 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -24,6 +24,7 @@ require('./edit')(Messaging); require('./rooms')(Messaging); require('./unread')(Messaging); require('./notifications')(Messaging); +require('./pins')(Messaging); Messaging.notificationSettings = Object.create(null); Messaging.notificationSettings.NONE = 1; diff --git a/src/messaging/pins.js b/src/messaging/pins.js new file mode 100644 index 0000000000..a2581487df --- /dev/null +++ b/src/messaging/pins.js @@ -0,0 +1,36 @@ +'use strict'; + +const db = require('../database'); + +module.exports = function (Messaging) { + Messaging.pinMessage = async (mid, roomId) => { + const isMessageInRoom = await db.isSortedSetMember(`chat:room:${roomId}:mids`, mid); + if (isMessageInRoom) { + await db.sortedSetAdd(`chat:room:${roomId}:mids:pinned`, Date.now(), mid); + await Messaging.setMessageFields(mid, { pinned: 1 }); + } + }; + + Messaging.unpinMessage = async (mid, roomId) => { + const isMessageInRoom = await db.isSortedSetMember(`chat:room:${roomId}:mids`, mid); + if (isMessageInRoom) { + await db.sortedSetRemove(`chat:room:${roomId}:mids:pinned`, mid); + await Messaging.setMessageFields(mid, { pinned: 0 }); + } + }; + + Messaging.getPinnedMessages = async (roomId, uid, start, stop) => { + const mids = await db.getSortedSetRevRange(`chat:room:${roomId}:mids:pinned`, start, stop); + if (!mids.length) { + return []; + } + + const messageData = await Messaging.getMessagesData(mids, uid, roomId, true); + messageData.forEach((msg, i) => { + if (msg) { + msg.index = start + i; + } + }); + return messageData; + }; +}; diff --git a/src/routes/write/chats.js b/src/routes/write/chats.js index 3334cb377f..8bcaef83b4 100644 --- a/src/routes/write/chats.js +++ b/src/routes/write/chats.js @@ -32,5 +32,8 @@ module.exports = function () { setupApiRoute(router, 'post', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.restore); setupApiRoute(router, 'delete', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.delete); + setupApiRoute(router, 'put', '/:roomId/messages/:mid/pin', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.pin); + setupApiRoute(router, 'delete', '/:roomId/messages/:mid/pin', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.unpin); + return router; }; diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 0595e1b59f..cfb83d7a16 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -251,4 +251,18 @@ SocketModules.chats.searchMessages = async (socket, data) => { return messageData.filter(msg => msg && !msg.deleted && msg.timestamp > userjoinTimestamp); }; +SocketModules.chats.loadPinnedMessages = async (socket, data) => { + if (!data || !data.roomId || !utils.isNumber(data.start)) { + throw new Error('[[error:invalid-data]]'); + } + const isInRoom = await Messaging.isUserInRoom(socket.uid, data.roomId); + if (!isInRoom) { + throw new Error('[[error:no-privileges]]'); + } + const start = parseInt(data.start, 10) || 0; + const pinnedMsgs = await Messaging.getPinnedMessages(data.roomId, socket.uid, start, start + 49); + return pinnedMsgs; +}; + + require('../promisify')(SocketModules);