diff --git a/Gruntfile.js b/Gruntfile.js index 6c02efa808..dcfa831cd6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -49,8 +49,8 @@ module.exports = function (grunt) { if (!pluginList.includes('nodebb-plugin-composer-default')) { pluginList.push('nodebb-plugin-composer-default'); } - if (!pluginList.includes('nodebb-theme-persona')) { - pluginList.push('nodebb-theme-persona'); + if (!pluginList.includes('nodebb-theme-harmony')) { + pluginList.push('nodebb-theme-harmony'); } } diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index a76f180081..4aa4915bc1 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -230,6 +230,7 @@ "not-in-room": "User not in room", "cant-kick-self": "You can't kick yourself from the group", "no-users-selected": "No user(s) selected", + "no-groups-selected": "No group(s) selected", "invalid-home-page-route": "Invalid home page route", "invalid-session": "Invalid Session", diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 4a752ea387..07f0f4515d 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -27,15 +27,29 @@ "chat.three_months": "3 Months", "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.public-rooms": "Public Rooms (%1)", + "chat.private-rooms": "Private Rooms (%1)", + "chat.create-room": "Create Chat Room", + "chat.private.option": "Private (Only visible to users added to room)", + "chat.public.option": "Public (Visible to every user in selected groups)", + "chat.public.groups-help": "To create a chat room that is visible to all users select registered-users from the group list.", "chat.manage-room": "Manage Chat Room", + "chat.add-user": "Add User", + "chat.select-groups": "Select Groups", "chat.add-user-help": "Search for users here. When selected, the user will be added to the chat. The new user will not be able to see chat messages written before they were added to the conversation. Only room owners () may remove users from chat rooms.", "chat.confirm-chat-with-dnd-user": "This user has set their status to DnD(Do not disturb). Do you still want to chat with them?", + "chat.room-name-optional": "Room Name (Optional)", "chat.rename-room": "Rename Room", "chat.rename-placeholder": "Enter your room name here", "chat.rename-help": "The room name set here will be viewable by all participants in the room.", - "chat.leave": "Leave Chat", + "chat.leave": "Leave", + "chat.leave-room": "Leave Room", "chat.leave-prompt": "Are you sure you wish to leave this chat?", "chat.leave-help": "Leaving this chat will remove you from future correspondence in this chat. If you are re-added in the future, you will not see any chat history from prior to your re-joining.", + "chat.delete": "Delete", + "chat.delete-room": "Delete Room", + "chat.delete-prompt": "Are you sure you wish to delete this chat room?", "chat.in-room": "In this room", "chat.kick": "Kick", "chat.show-ip": "Show IP", diff --git a/public/openapi/components/schemas/Chats.yaml b/public/openapi/components/schemas/Chats.yaml index 91c41f777b..7f8f1b8b85 100644 --- a/public/openapi/components/schemas/Chats.yaml +++ b/public/openapi/components/schemas/Chats.yaml @@ -9,9 +9,19 @@ RoomObject: description: unique identifier for the chat room roomName: type: string + description: the name of the room, if set this is displayed instead of the usernames groupChat: type: boolean - description: whether the chat room is a group chat or not + description: whether the chat room is a group chat or not (if more than 2 users it is a group chat) + public: + type: boolean + description: whether the chat room is public or private + userCount: + type: number + description: number of users in this chat room + timestamp: + type: number + description: Timestamp of when room was created MessageObject: type: object properties: @@ -92,8 +102,6 @@ MessageObject: type: number newSet: type: boolean - cleanedContent: - type: string RoomUserList: type: object properties: @@ -132,6 +140,8 @@ RoomUserList: type: boolean canKick: type: boolean + index: + type: number RoomObjectFull: # Messaging.loadRoom allOf: diff --git a/public/openapi/read/user/userslug/chats/roomid.yaml b/public/openapi/read/user/userslug/chats/roomid.yaml index 767ea88d0c..4f8b26e6a4 100644 --- a/public/openapi/read/user/userslug/chats/roomid.yaml +++ b/public/openapi/read/user/userslug/chats/roomid.yaml @@ -30,6 +30,13 @@ get: type: number roomName: type: string + public: + type: boolean + userCount: + type: number + timestamp: + type: number + description: Timestamp of when room was created messages: type: array items: @@ -101,8 +108,6 @@ get: type: boolean index: type: number - cleanedContent: - type: string isOwner: type: boolean isOwner: @@ -139,6 +144,8 @@ get: example: "#f44336" isOwner: type: boolean + index: + type: number canReply: type: boolean groupChat: @@ -153,6 +160,8 @@ get: type: boolean isAdminOrGlobalMod: type: boolean + isAdmin: + type: boolean rooms: type: array items: @@ -166,6 +175,162 @@ get: type: number roomName: type: string + public: + type: boolean + userCount: + type: number + timestamp: + type: number + description: Timestamp of when room was created + users: + type: array + items: + type: object + properties: + uid: + type: number + description: A user identifier + username: + type: string + description: A friendly name for a given user account + displayname: + type: string + description: This is either username or fullname depending on forum and user settings + userslug: + type: string + description: An URL-safe variant of the username (i.e. lower-cased, spaces + removed, etc.) + picture: + nullable: true + type: string + status: + type: string + lastonline: + type: number + icon:text: + type: string + description: A single-letter representation of a username. This is used in the + auto-generated icon given to users without + an avatar + icon:bgColor: + type: string + description: A six-character hexadecimal colour code assigned to the user. This + value is used in conjunction with + `icon:text` for the user's auto-generated + icon + example: "#f44336" + lastonlineISO: + type: string + groupChat: + type: boolean + unread: + type: boolean + teaser: + type: object + properties: + fromuid: + type: number + content: + type: string + timestamp: + type: number + timestampISO: + type: string + description: An ISO 8601 formatted date string (complementing `timestamp`) + user: + type: object + properties: + uid: + type: number + description: A user identifier + username: + type: string + description: A friendly name for a given user account + displayname: + type: string + description: This is either username or fullname depending on forum and user settings + userslug: + type: string + description: An URL-safe variant of the username (i.e. lower-cased, spaces + removed, etc.) + picture: + nullable: true + type: string + status: + type: string + lastonline: + type: number + icon:text: + type: string + description: A single-letter representation of a username. This is used in the + auto-generated icon given to users + without an avatar + icon:bgColor: + type: string + description: A six-character hexadecimal colour code assigned to the user. This + value is used in conjunction with + `icon:text` for the user's + auto-generated icon + example: "#f44336" + lastonlineISO: + type: string + nullable: true + lastUser: + type: object + properties: + uid: + type: number + description: A user identifier + username: + type: string + description: A friendly name for a given user account + displayname: + type: string + description: This is either username or fullname depending on forum and user settings + userslug: + type: string + description: An URL-safe variant of the username (i.e. lower-cased, spaces + removed, etc.) + picture: + nullable: true + type: string + status: + type: string + lastonline: + type: number + icon:text: + type: string + description: A single-letter representation of a username. This is used in the + auto-generated icon given to users without + an avatar + icon:bgColor: + type: string + description: A six-character hexadecimal colour code assigned to the user. This + value is used in conjunction with + `icon:text` for the user's auto-generated + icon + example: "#f44336" + lastonlineISO: + type: string + usernames: + type: string + chatWithMessage: + type: string + publicRooms: + type: array + items: + type: object + properties: + owner: + oneOf: + - type: number + - type: string + roomId: + type: number + roomName: + type: string + public: + type: boolean users: type: array items: @@ -300,6 +465,8 @@ get: type: string chatWithMessage: type: string + privateRoomCount: + type: number nextStart: type: number title: @@ -315,4 +482,6 @@ get: type: boolean chatWithMessage: type: string + bodyClasses: + type: array - $ref: ../../../../components/schemas/CommonProps.yaml#/CommonProps \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 101da200d1..e12e1ce2db 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -198,6 +198,8 @@ paths: $ref: 'write/admin/analytics.yaml' /admin/analytics/{set}: $ref: 'write/admin/analytics/set.yaml' + /admin/chats/{roomId}: + $ref: 'write/admin/chats/roomId.yaml' /admin/tokens: $ref: 'write/admin/tokens.yaml' /admin/tokens/{token}: diff --git a/public/openapi/write/admin/chats/roomId.yaml b/public/openapi/write/admin/chats/roomId.yaml new file mode 100644 index 0000000000..a7d2317cd2 --- /dev/null +++ b/public/openapi/write/admin/chats/roomId.yaml @@ -0,0 +1,26 @@ +delete: + tags: + - admin + summary: delete chat room + description: This operation deletes a chat room from the database + parameters: + - in: path + name: roomId + schema: + type: number + description: The roomId to be deleted + example: 1 + required: true + responses: + '200': + description: Chat room deleted + 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/openapi/write/chats/roomId.yaml b/public/openapi/write/chats/roomId.yaml index 3473c92e1b..62ae9df2bd 100644 --- a/public/openapi/write/chats/roomId.yaml +++ b/public/openapi/write/chats/roomId.yaml @@ -86,8 +86,6 @@ post: newSet: type: boolean description: Whether the message is considered part of a new "set" of messages. It is used in the frontend UI for explicitly denoting that a time gap existed between messages. - cleanedContent: - type: string mid: type: number put: diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 39cf3519c5..c19773ecbb 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -3,11 +3,12 @@ define('forum/chats', [ 'components', - 'translator', 'mousetrap', 'forum/chats/recent', - 'forum/chats/search', + 'forum/chats/create', + 'forum/chats/manage', 'forum/chats/messages', + 'forum/chats/user-list', 'composer/autocomplete', 'hooks', 'bootbox', @@ -16,10 +17,10 @@ define('forum/chats', [ 'api', 'uploadHelpers', ], function ( - components, translator, mousetrap, - recentChats, search, messages, - autocomplete, hooks, bootbox, alerts, chatModule, - api, uploadHelpers + components, mousetrap, + recentChats, create, manage, messages, + userList, autocomplete, hooks, bootbox, + alerts, chatModule, api, uploadHelpers ) { const Chats = { initialised: false, @@ -27,13 +28,19 @@ define('forum/chats', [ }; let newMessage = false; + let chatNavWrapper = null; $(window).on('action:ajaxify.start', function () { Chats.destroyAutoComplete(ajaxify.data.roomId); + socket.emit('modules.chats.leave', ajaxify.data.roomId); + socket.emit('modules.chats.leavePublic', ajaxify.data.publicRooms.map(r => r.roomId)); }); Chats.init = function () { + $('.chats-full [data-bs-toggle="tooltip"]').tooltip(); + socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId)); const env = utils.findBootstrapEnvironment(); + chatNavWrapper = $('[component="chat/nav-wrapper"]'); if (!Chats.initialised) { Chats.addSocketListeners(); @@ -49,29 +56,31 @@ define('forum/chats', [ Chats.addHotkeys(); } - $(document).ready(function () { - hooks.fire('action:chat.loaded', $('.chats-full')); - }); - Chats.initialised = true; messages.scrollToBottom($('.expanded-chat ul.chat-content')); messages.wrapImagesInLinks($('.expanded-chat ul.chat-content')); - search.init(); + create.init(); + + hooks.fire('action:chat.loaded', $('.chats-full')); }; Chats.addEventListeners = function () { - Chats.addSendHandlers(ajaxify.data.roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]')); + const { roomId } = ajaxify.data; + const mainWrapper = $('[component="chat/main-wrapper"]'); + 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'), ajaxify.data.roomId); - Chats.addMemberHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="members"]')); - Chats.addRenameHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="rename"]')); - Chats.addLeaveHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="leave"]')); - Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content')); + Chats.addActionHandlers(components.get('chat/messages'), roomId); + Chats.addManageHandler(roomId, chatControls.find('[data-action="members"]')); + 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, $('.chat-content')); Chats.addScrollBottomHandler($('.chat-content')); - Chats.addCharactersLeftHandler($('[component="chat/main-wrapper"]')); - Chats.addTextareaResizeHandler($('[component="chat/main-wrapper"]')); - Chats.addIPHandler($('[component="chat/main-wrapper"]')); - Chats.createAutoComplete(ajaxify.data.roomId, $('[component="chat/input"]')); + Chats.addCharactersLeftHandler(mainWrapper); + Chats.addTextareaResizeHandler(mainWrapper); + Chats.addIPHandler(mainWrapper); + Chats.createAutoComplete(roomId, $('[component="chat/input"]')); Chats.addUploadHandler({ dragDropAreaEl: $('.chats-full'), pasteEl: $('[component="chat/input"]'), @@ -83,6 +92,28 @@ define('forum/chats', [ $('[data-action="close"]').on('click', function () { Chats.switchChat(); }); + userList.init(roomId, mainWrapper); + Chats.addPublicRoomSortHandler(); + }; + + Chats.addPublicRoomSortHandler = function () { + if (app.user.isAdmin && !utils.isMobile()) { + app.loadJQueryUI(() => { + const publicRoomList = $('[component="chat/public"]'); + publicRoomList.sortable({ + handle: '[component="chat/public/room/sort/handle"]', + axis: 'y', + update: async function () { + const data = { roomIds: [], scores: [] }; + publicRoomList.find('[data-roomid]').each((idx, el) => { + data.roomIds.push($(el).attr('data-roomid')); + data.scores.push(idx); + }); + await socket.emit('modules.chats.sortPublicRooms', data); + }, + }); + }); + } }; Chats.addUploadHandler = function (options) { @@ -141,7 +172,7 @@ define('forum/chats', [ Chats.addScrollHandler = function (roomId, uid, el) { let loading = false; - el.off('scroll').on('scroll', function () { + el.off('scroll').on('scroll', utils.debounce(function () { messages.toggleScrollUpAlert(el); if (loading) { return; @@ -176,7 +207,7 @@ define('forum/chats', [ loading = false; }); }).catch(alerts.error); - }); + }, 100)); }; Chats.addScrollBottomHandler = function (chatContent) { @@ -208,7 +239,7 @@ define('forum/chats', [ }; Chats.addActionHandlers = function (element, roomId) { - element.on('click', '[data-action]', function () { + element.on('click', '[data-mid] [data-action]', function () { const messageId = $(this).parents('[data-mid]').attr('data-mid'); const action = this.getAttribute('data-action'); @@ -231,18 +262,16 @@ define('forum/chats', [ Chats.addHotkeys = function () { mousetrap.bind('ctrl+up', function () { - const activeContact = $('.chats-list .bg-info'); - const prev = activeContact.prev(); - - if (prev.length) { + const activeContact = $('.chats-list .active'); + const prev = activeContact.prevAll('[data-roomid]').first(); + if (prev.length && prev.attr('data-roomid')) { Chats.switchChat(prev.attr('data-roomid')); } }); mousetrap.bind('ctrl+down', function () { - const activeContact = $('.chats-list .bg-info'); - const next = activeContact.next(); - - if (next.length) { + const activeContact = $('.chats-list .active'); + const next = activeContact.nextAll('[data-roomid]').first(); + if (next.length && next.attr('data-roomid')) { Chats.switchChat(next.attr('data-roomid')); } }); @@ -260,50 +289,8 @@ define('forum/chats', [ }); }; - Chats.addMemberHandler = function (roomId, buttonEl) { - let modal; - - buttonEl.on('click', function () { - app.parseAndTranslate('modals/manage-room', {}, function (html) { - modal = bootbox.dialog({ - title: '[[modules:chat.manage-room]]', - message: html, - }); - - modal.attr('component', 'chat/manage-modal'); - - Chats.refreshParticipantsList(roomId, modal); - Chats.addKickHandler(roomId, modal); - - const searchInput = modal.find('input'); - const errorEl = modal.find('.text-danger'); - require(['autocomplete', 'translator'], function (autocomplete, translator) { - autocomplete.user(searchInput, function (event, selected) { - errorEl.text(''); - api.post(`/chats/${roomId}/users`, { - uids: [selected.item.user.uid], - }).then((body) => { - Chats.refreshParticipantsList(roomId, modal, body); - searchInput.val(''); - }).catch((err) => { - translator.translate(err.message, function (translated) { - errorEl.text(translated); - }); - }); - }); - }); - }); - }); - }; - - Chats.addKickHandler = function (roomId, modal) { - modal.on('click', '[data-action="kick"]', function () { - const uid = parseInt(this.getAttribute('data-uid'), 10); - - api.del(`/chats/${roomId}/users/${uid}`, {}).then((body) => { - Chats.refreshParticipantsList(roomId, modal, body); - }).catch(alerts.error); - }); + Chats.addManageHandler = function (roomId, buttonEl) { + manage.init(roomId, buttonEl); }; Chats.addLeaveHandler = function (roomId, buttonEl) { @@ -330,21 +317,27 @@ define('forum/chats', [ }); }; - Chats.refreshParticipantsList = async (roomId, modal, data) => { - const listEl = modal.find('.list-group'); - - if (!data) { - try { - data = await api.get(`/chats/${roomId}/users`, {}); - } catch (err) { - translator.translate('[[error:invalid-data]]', function (translated) { - listEl.find('li').text(translated); - }); - } - } - - app.parseAndTranslate('partials/chats/manage-room-users', data, function (html) { - listEl.html(html); + Chats.addDeleteHandler = function (roomId, buttonEl) { + buttonEl.on('click', function () { + bootbox.confirm({ + size: 'small', + title: '[[modules:chat.delete]]', + message: '
[[modules:chat.delete-prompt]]
', + callback: function (ok) { + if (ok) { + api.del(`/admin/chats/${roomId}`, {}).then(() => { + // Return user to chats page. If modal, close modal. + const modal = buttonEl.parents('.chat-modal'); + if (modal.length) { + chatModule.close(modal); + } else { + Chats.destroyAutoComplete(roomId); + ajaxify.go('chats'); + } + }).catch(alerts.error); + } + }, + }); }); }; @@ -362,18 +355,16 @@ define('forum/chats', [ save: { label: '[[global:save]]', className: 'btn-primary', - callback: submit, + callback: function () { + api.put(`/chats/${roomId}`, { + name: modal.find('#roomName').val(), + }).catch(alerts.error); + }, }, }, }); }); }); - - function submit() { - api.put(`/chats/${roomId}`, { - name: modal.find('#roomName').val(), - }).catch(alerts.error); - } }; Chats.addSendHandlers = function (roomId, inputEl, sendEl) { @@ -452,37 +443,41 @@ define('forum/chats', [ roomid = ''; } Chats.destroyAutoComplete(ajaxify.data.roomId); + socket.emit('modules.chats.leave', ajaxify.data.roomId); const url = 'user/' + ajaxify.data.userslug + '/chats/' + roomid + window.location.search; - if (self.fetch) { - fetch(config.relative_path + '/api/' + url, { credentials: 'include' }) - .then(function (response) { - if (response.ok) { - response.json().then(function (payload) { - app.parseAndTranslate('partials/chats/message-window', payload, function (html) { - components.get('chat/main-wrapper').html(html); - html.find('.timeago').timeago(); - ajaxify.data = payload; - Chats.setActive(); - Chats.addEventListeners(); - hooks.fire('action:chat.loaded', $('.chats-full')); - messages.scrollToBottom($('.expanded-chat ul.chat-content')); - if (history.pushState) { - history.pushState({ - url: url, - }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url); - } - }); - }); - } else { - console.warn('[search] Received ' + response.status); - } - }) - .catch(function (error) { - console.warn('[search] ' + error.message); - }); - } else { - ajaxify.go(url); + if (!self.fetch) { + return ajaxify.go(url); } + const params = new URL(document.location).searchParams; + params.set('switch', 1); + const dataUrl = `${config.relative_path}/api/user/${ajaxify.data.userslug}/chats/${roomid}?${params.toString()}`; + fetch(dataUrl, { credentials: 'include' }) + .then(async function (response) { + if (!response.ok) { + return console.warn('[search] Received ' + response.status); + } + const payload = await response.json(); + const html = await app.parseAndTranslate('partials/chats/message-window', payload); + const mainWrapper = components.get('chat/main-wrapper'); + mainWrapper.html(html); + chatNavWrapper = $('[component="chat/nav-wrapper"]'); + html.find('.timeago').timeago(); + ajaxify.data = { ...ajaxify.data, ...payload }; + $('body').addClass(ajaxify.data.bodyClass); + mainWrapper.find('[data-bs-toggle="tooltip"]').tooltip(); + Chats.setActive(); + Chats.addEventListeners(); + hooks.fire('action:chat.loaded', $('.chats-full')); + messages.scrollToBottom(mainWrapper.find('.expanded-chat ul.chat-content')); + if (history.pushState) { + history.pushState({ + url: url, + }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url); + } + }) + .catch(function (error) { + console.warn('[search] ' + error.message); + }); }; Chats.addGlobalEventListeners = function () { @@ -496,7 +491,11 @@ define('forum/chats', [ Chats.addSocketListeners = function () { socket.on('event:chats.receive', function (data) { + if (chatModule.isFromBlockedUser(data.fromUid)) { + return; + } if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { + data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0; if (!newMessage) { newMessage = data.self === 0; } @@ -504,31 +503,19 @@ define('forum/chats', [ data.message.timestamp = Math.min(Date.now(), data.message.timestamp); data.message.timestampISO = utils.toISOString(data.message.timestamp); messages.appendChatMessage($('.expanded-chat .chat-content'), data.message); - } else if (ajaxify.data.template.chats) { - const roomEl = $('[data-roomid=' + data.roomId + ']'); - - if (roomEl.length > 0) { - roomEl.addClass('unread'); + } + }); - const markEl = roomEl.find('.mark-read').get(0); - if (markEl) { - markEl.querySelector('.read').classList.add('hidden'); - markEl.querySelector('.unread').classList.remove('hidden'); - } - } else { - const recentEl = components.get('chat/recent'); - app.parseAndTranslate('partials/chats/recent_room', { - rooms: { - roomId: data.roomId, - lastUser: data.message.fromUser, - usernames: data.message.fromUser.username, - unread: true, - }, - }, function (html) { - recentEl.prepend(html); - }); - } + socket.on('event:chats.public.unread', function (data) { + if ( + chatModule.isFromBlockedUser(data.fromUid) || + chatModule.isLookingAtRoom(data.roomId) || + app.user.uid === parseInt(data.fromUid, 10) + ) { + return; } + Chats.markChatPageElUnread(data); + Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']')); }); socket.on('event:user_status_change', function (data) { @@ -539,33 +526,55 @@ define('forum/chats', [ socket.on('event:chats.roomRename', function (data) { const roomEl = components.get('chat/recent/room', data.roomId); - const titleEl = roomEl.find('[component="chat/title"]'); - ajaxify.data.roomName = data.newName; - - titleEl.text(data.newName); + if (roomEl.length) { + const titleEl = roomEl.find('[component="chat/room/title"]'); + ajaxify.data.roomName = data.newName; + titleEl.text(data.newName); + } }); socket.on('event:chats.mark', ({ roomId, state }) => { - const roomEls = document.querySelectorAll(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"]`); - - roomEls.forEach((roomEl) => { - roomEl.classList[state ? 'add' : 'remove']('unread'); - - const markEl = roomEl.querySelector('.mark-read'); - if (markEl) { - markEl.querySelector('.read').classList[state ? 'add' : 'remove']('hidden'); - markEl.querySelector('.unread').classList[state ? 'remove' : 'add']('hidden'); + const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`); + roomEls.each((idx, el) => { + const roomEl = $(el); + chatModule.markChatElUnread(roomEl, state === 1); + if (state === 0) { + Chats.updatePublicRoomUnreadCount(roomEl, 0); } }); }); }; + Chats.markChatPageElUnread = function (data) { + if (!ajaxify.data.template.chats) { + return; + } + + const roomEl = chatNavWrapper.find('[data-roomid=' + data.roomId + ']'); + chatModule.markChatElUnread(roomEl, true); + }; + + Chats.increasePublicRoomUnreadCount = function (roomEl) { + const unreadCountEl = roomEl.find('[component="chat/public/room/unread/count"]'); + const newCount = (parseInt(unreadCountEl.attr('data-count'), 10) || 0) + 1; + Chats.updatePublicRoomUnreadCount(roomEl, newCount); + }; + + Chats.updatePublicRoomUnreadCount = function (roomEl, count) { + const unreadCountEl = roomEl.find('[component="chat/public/room/unread/count"]'); + const countText = count > 50 ? '50+' : count; + unreadCountEl.toggleClass('hidden', count <= 0).text(countText).attr('data-count', count); + }; + Chats.setActive = function () { + chatNavWrapper.find('[data-roomid]').removeClass('active'); if (ajaxify.data.roomId) { - const chatEl = document.querySelector(`[component="chat/recent"] [data-roomid="${ajaxify.data.roomId}"]`); - if (chatEl.classList.contains('unread')) { + socket.emit('modules.chats.enter', ajaxify.data.roomId); + const chatEl = chatNavWrapper.find(`[data-roomid="${ajaxify.data.roomId}"]`); + chatEl.addClass('active'); + if (chatEl.hasClass('unread')) { api.del(`/chats/${ajaxify.data.roomId}/state`, {}); - chatEl.classList.remove('unread'); + chatEl.removeClass('unread'); } if (!utils.isMobile()) { @@ -573,12 +582,10 @@ define('forum/chats', [ } messages.updateTextAreaHeight($(`[component="chat/messages"][data-roomid="${ajaxify.data.roomId}"]`)); } - $('.chats-list [data-roomid]').removeClass('active'); - $('.chats-list [data-roomid="' + ajaxify.data.roomId + '"]').addClass('active'); - components.get('chat/nav-wrapper').attr('data-loaded', ajaxify.data.roomId ? '1' : '0'); + chatNavWrapper.attr('data-loaded', ajaxify.data.roomId ? '1' : '0'); }; - return Chats; }); + diff --git a/public/src/client/chats/create.js b/public/src/client/chats/create.js new file mode 100644 index 0000000000..9f532f020d --- /dev/null +++ b/public/src/client/chats/create.js @@ -0,0 +1,85 @@ +'use strict'; + + +define('forum/chats/create', [ + 'components', 'api', 'alerts', 'forum/chats/search', +], function (components, api, alerts, search) { + const create = {}; + create.init = function () { + components.get('chat/create').on('click', handleCreate); + }; + + async function handleCreate() { + let groups = []; + if (app.user.isAdmin) { + groups = await socket.emit('groups.getChatGroups', {}); + } + const html = await app.parseAndTranslate('modals/create-room', { + user: app.user, + groups: groups, + }); + + const modal = bootbox.dialog({ + title: '[[modules:chat.create-room]]', + message: html, + buttons: { + save: { + label: '[[global:create]]', + className: 'btn-primary', + callback: async function () { + const roomName = modal.find('[component="chat/room/name"]').val(); + const uids = modal.find('[component="chat/room/users"] [component="chat/user"]').find('[data-uid]').map( + (i, el) => $(el).attr('data-uid') + ).get(); + const type = modal.find('[component="chat/room/type"]').val(); + const groups = modal.find('[component="chat/room/groups"]').val(); + + if (type === 'private' && !uids.length) { + alerts.error('[[error:no-users-selected]]'); + return false; + } + if (type === 'public' && !groups) { + alerts.error('[[error:no-groups-selected]]'); + return false; + } + await createRoom({ + roomName: roomName, + uids: uids, + type: type, + groups: groups, + }); + }, + }, + }, + }); + + const chatRoomUsersList = modal.find('[component="chat/room/users"]'); + + search.init({ + onSelect: async function (user) { + const html = await app.parseAndTranslate('modals/create-room', 'selectedUsers', { selectedUsers: [user] }); + chatRoomUsersList.append(html); + }, + }); + + chatRoomUsersList.on('click', '[component="chat/room/users/remove"]', function () { + $(this).parents('[data-uid]').remove(); + }); + + + modal.find('[component="chat/room/type"]').on('change', function () { + const type = $(this).val(); + modal.find('[component="chat/room/public/options"]').toggleClass('hidden', type === 'private'); + }); + } + + async function createRoom(params) { + if (!app.user.uid) { + return alerts.error('[[error:not-logged-in]]'); + } + const { roomId } = await api.post(`/chats`, params); + ajaxify.go('chats/' + roomId); + } + + return create; +}); diff --git a/public/src/client/chats/manage.js b/public/src/client/chats/manage.js new file mode 100644 index 0000000000..2bb2ec41c4 --- /dev/null +++ b/public/src/client/chats/manage.js @@ -0,0 +1,106 @@ +'use strict'; + + +define('forum/chats/manage', [ + 'api', 'alerts', 'translator', 'autocomplete', 'forum/chats/user-list', +], function (api, alerts, translator, autocomplete, userList) { + const manage = {}; + + manage.init = function (roomId, buttonEl) { + let modal; + + buttonEl.on('click', async function () { + let groups = []; + if (app.user.isAdmin) { + groups = await socket.emit('groups.getChatGroups', {}); + if (Array.isArray(ajaxify.data.groups)) { + groups.forEach((g) => { + g.selected = ajaxify.data.groups.includes(g.name); + }); + } + } + + const html = await app.parseAndTranslate('modals/manage-room', { + groups, + user: app.user, + group: ajaxify.data, + }); + modal = bootbox.dialog({ + title: '[[modules:chat.manage-room]]', + message: html, + }); + + modal.attr('component', 'chat/manage-modal'); + + refreshParticipantsList(roomId, modal); + addKickHandler(roomId, modal); + + const userListEl = modal.find('[component="chat/manage/user/list"]'); + const userListElSearch = modal.find('[component="chat/manage/user/list/search"]'); + userList.addSearchHandler(roomId, userListElSearch, async (data) => { + if (userListElSearch.val()) { + userListEl.html(await app.parseAndTranslate('partials/chats/manage-room-users', data)); + } else { + refreshParticipantsList(roomId, modal); + } + }); + + userList.addInfiniteScrollHandler(roomId, userListEl, async (listEl, data) => { + listEl.append(await app.parseAndTranslate('partials/chats/manage-room-users', data)); + }); + + const searchInput = modal.find('[component="chat/manage/user/add/search"]'); + const errorEl = modal.find('.text-danger'); + autocomplete.user(searchInput, function (event, selected) { + errorEl.text(''); + api.post(`/chats/${roomId}/users`, { + uids: [selected.item.user.uid], + }).then((body) => { + refreshParticipantsList(roomId, modal, body); + searchInput.val(''); + }).catch((err) => { + translator.translate(err.message, function (translated) { + errorEl.text(translated); + }); + }); + }); + + modal.find('[component="chat/manage/save/groups"]').on('click', (ev) => { + const btn = $(ev.target); + api.put(`/chats/${roomId}`, { + groups: modal.find('[component="chat/room/groups"]').val(), + }).then((payload) => { + ajaxify.data.groups = payload.groups; + btn.addClass('btn-success'); + setTimeout(() => btn.removeClass('btn-success'), 1000); + }).catch(alerts.error); + }); + }); + }; + + function addKickHandler(roomId, modal) { + modal.on('click', '[data-action="kick"]', function () { + const uid = parseInt(this.getAttribute('data-uid'), 10); + + api.del(`/chats/${roomId}/users/${uid}`, {}).then((body) => { + refreshParticipantsList(roomId, modal, body); + }).catch(alerts.error); + }); + } + + async function refreshParticipantsList(roomId, modal, data) { + const listEl = modal.find('[component="chat/manage/user/list"]'); + + if (!data) { + try { + data = await api.get(`/chats/${roomId}/users`, {}); + } catch (err) { + listEl.find('li').text(await translator.translate('[[error:invalid-data]]')); + } + } + + listEl.html(await app.parseAndTranslate('partials/chats/manage-room-users', data)); + } + + return manage; +}); diff --git a/public/src/client/chats/messages.js b/public/src/client/chats/messages.js index f51b261acf..0105c82666 100644 --- a/public/src/client/chats/messages.js +++ b/public/src/client/chats/messages.js @@ -72,8 +72,9 @@ define('forum/chats/messages', [ } messages.appendChatMessage = function (chatContentEl, data) { - const lastSpeaker = parseInt(chatContentEl.find('.chat-message').last().attr('data-uid'), 10); - const lasttimestamp = parseInt(chatContentEl.find('.chat-message').last().attr('data-timestamp'), 10); + const lastMsgEl = chatContentEl.find('.chat-message').last(); + 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) || parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3); @@ -91,6 +92,12 @@ define('forum/chats/messages', [ messages.onMessagesAddedToDom(newMessage); if (isAtBottom) { messages.scrollToBottom(chatContentEl); + // remove some message elements if there are too many + const chatMsgEls = chatContentEl.find('[data-mid]'); + if (chatMsgEls.length > 150) { + const removeCount = chatMsgEls.length - 150; + chatMsgEls.slice(0, removeCount).remove(); + } } hooks.fire('action:chat.received', { @@ -239,17 +246,23 @@ define('forum/chats/messages', [ } function onChatMessageDeleted(messageId) { - components.get('chat/message', messageId) - .toggleClass('deleted', true) - .find('[component="chat/message/body"]') - .translateHtml('[[modules:chat.message-deleted]]'); + const msgEl = components.get('chat/message', messageId); + const isSelf = parseInt(msgEl.attr('data-uid'), 10) === app.user.uid; + msgEl.toggleClass('deleted', true); + if (!isSelf) { + msgEl.find('[component="chat/message/body"]') + .translateHtml('[[modules:chat.message-deleted]]
'); + } } function onChatMessageRestored(message) { - components.get('chat/message', message.messageId) - .toggleClass('deleted', false) - .find('[component="chat/message/body"]') - .html(message.content); + const msgEl = components.get('chat/message', message.messageId); + const isSelf = parseInt(msgEl.attr('data-uid'), 10) === app.user.uid; + msgEl.toggleClass('deleted', false); + if (!isSelf) { + msgEl.find('[component="chat/message/body"]') + .translateHtml(message.content); + } } messages.delete = function (messageId, roomId) { diff --git a/public/src/client/chats/recent.js b/public/src/client/chats/recent.js index 95532a3df0..68a01b62ea 100644 --- a/public/src/client/chats/recent.js +++ b/public/src/client/chats/recent.js @@ -1,13 +1,13 @@ 'use strict'; -define('forum/chats/recent', ['alerts', 'api'], function (alerts, api) { +define('forum/chats/recent', ['alerts', 'api', 'chat'], function (alerts, api, chat) { const recent = {}; recent.init = function () { require(['forum/chats'], function (Chats) { - $('[component="chat/recent"]') - .on('click', '[component="chat/recent/room"]', function (e) { + $('[component="chat/nav-wrapper"]') + .on('click', '[component="chat/recent/room"], [component="chat/public/room"]', function (e) { e.stopPropagation(); e.preventDefault(); const roomId = this.getAttribute('data-roomid'); @@ -16,21 +16,7 @@ define('forum/chats/recent', ['alerts', 'api'], function (alerts, api) { .on('click', '.mark-read', function (e) { e.stopPropagation(); const chatEl = this.closest('[data-roomid]'); - const state = !chatEl.classList.contains('unread'); // this is the new state - const roomId = chatEl.getAttribute('data-roomid'); - api[state ? 'put' : 'del'](`/chats/${roomId}/state`, {}).catch((err) => { - alerts.error(err); - - // Revert on failure - chatEl.classList[state ? 'remove' : 'add']('unread'); - this.querySelector('.unread').classList[state ? 'add' : 'remove']('hidden'); - this.querySelector('.read').classList[!state ? 'add' : 'remove']('hidden'); - }); - - // Immediate feedback - chatEl.classList[state ? 'add' : 'remove']('unread'); - this.querySelector('.unread').classList[!state ? 'add' : 'remove']('hidden'); - this.querySelector('.read').classList[state ? 'add' : 'remove']('hidden'); + chat.toggleReadState(chatEl); }); $('[component="chat/recent"]').on('scroll', function () { diff --git a/public/src/client/chats/search.js b/public/src/client/chats/search.js index 1641efcb1e..bb0d160328 100644 --- a/public/src/client/chats/search.js +++ b/public/src/client/chats/search.js @@ -5,22 +5,35 @@ define('forum/chats/search', [ 'components', 'api', 'alerts', ], function (components, api, alerts) { const search = {}; + let users = []; - search.init = function () { + search.init = function (options) { + options = options || {}; + users.length = 0; components.get('chat/search').on('keyup', utils.debounce(doSearch, 250)); const chatsListEl = $('[component="chat/search/list"]'); chatsListEl.on('click', '[data-uid]', function () { - onUserClick($(this).attr('data-uid')); + if (options.onSelect) { + options.onSelect( + users.find(u => parseInt(u.uid, 10) === parseInt($(this).attr('data-uid'), 10)) + ); + } + clearInputAndResults(chatsListEl); }); }; + function clearInputAndResults(chatsListEl) { + components.get('chat/search').val(''); + removeResults(chatsListEl); + chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden'); + chatsListEl.find('[component="chat/search/start-typing"]').removeClass('hidden'); + } + function doSearch() { const chatsListEl = $('[component="chat/search/list"]'); const username = components.get('chat/search').val(); if (!username) { - removeResults(chatsListEl); - chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden'); - return chatsListEl.find('[component="chat/search/start-typing"]').removeClass('hidden'); + return clearInputAndResults(chatsListEl); } chatsListEl.find('[component="chat/search/start-typing"]').addClass('hidden'); api.get('/api/users', { @@ -32,6 +45,7 @@ define('forum/chats/search', [ } function removeResults(chatsListEl) { + users.length = 0; chatsListEl.find('[data-uid]').remove(); } @@ -41,35 +55,15 @@ define('forum/chats/search', [ data.users = data.users.filter(function (user) { return parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); }); - + users = data.users; if (!data.users.length) { return chatsListEl.find('[component="chat/search/no-users"]').removeClass('hidden'); } chatsListEl.find('[component="chat/search/no-users"]').addClass('hidden'); - const html = await app.parseAndTranslate('chats', 'searchUsers', { searchUsers: data.users }); + const html = await app.parseAndTranslate('modals/create-room', 'searchUsers', { searchUsers: data.users }); chatsListEl.append(html); chatsListEl.parent().toggleClass('show', true); } - function onUserClick(uid) { - if (!uid) { - return; - } - socket.emit('modules.chats.hasPrivateChat', uid, function (err, roomId) { - if (err) { - return alerts.error(err); - } - if (roomId) { - require(['forum/chats'], function (chats) { - chats.switchChat(roomId); - }); - } else { - require(['chat'], function (chat) { - chat.newChat(uid); - }); - } - }); - } - return search; }); diff --git a/public/src/client/chats/user-list.js b/public/src/client/chats/user-list.js new file mode 100644 index 0000000000..cd4b5a8f50 --- /dev/null +++ b/public/src/client/chats/user-list.js @@ -0,0 +1,48 @@ +'use strict'; + + +define('forum/chats/user-list', ['api'], function (api) { + const userList = {}; + + userList.init = function (roomId, container) { + const userListEl = container.find('[component="chat/user/list"]'); + if (!userListEl.length) { + return; + } + container.find('[component="chat/user/list/btn"]').on('click', () => { + userListEl.toggleClass('hidden'); + }); + + userList.addInfiniteScrollHandler(roomId, userListEl, async (listEl, data) => { + listEl.append(await app.parseAndTranslate('partials/chats/user-list', 'users', data)); + }); + }; + + userList.addInfiniteScrollHandler = function (roomId, listEl, callback) { + 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 api.get(`/chats/${roomId}/users`, { + start: parseInt(lastIndex, 10) + 1, + }); + if (data && data.users.length) { + callback(listEl, data); + } + } + }, 200)); + }; + + userList.addSearchHandler = function (roomId, inputEl, callback) { + inputEl.on('keyup', utils.debounce(async () => { + const username = inputEl.val(); + const data = await socket.emit('modules.chats.searchMembers', { + username: username, + roomId: roomId, + }); + callback(data); + }, 200)); + }; + + return userList; +}); diff --git a/public/src/client/header/chat.js b/public/src/client/header/chat.js index 6b4a04aaf9..e384291945 100644 --- a/public/src/client/header/chat.js +++ b/public/src/client/header/chat.js @@ -1,6 +1,8 @@ 'use strict'; -define('forum/header/chat', ['components', 'hooks'], function (components, hooks) { +define('forum/header/chat', [ + 'components', 'hooks', +], function (components, hooks) { const chat = {}; chat.prepareDOM = function () { @@ -29,7 +31,20 @@ define('forum/header/chat', ['components', 'hooks'], function (components, hooks socket.removeListener('event:chats.roomRename', onRoomRename); socket.on('event:chats.roomRename', onRoomRename); - socket.on('event:unread.updateChatCount', function (count) { + socket.on('event:unread.updateChatCount', async function (data) { + if (data) { + const [chatModule, chatPage] = await app.require(['chat', 'forum/chats']); + if ( + chatModule.isFromBlockedUser(data.fromUid) || + chatModule.isLookingAtRoom(data.roomId) || + app.user.uid === parseInt(data.fromUid, 10) + ) { + return; + } + chatPage.markChatPageElUnread(data); + } + + let count = await socket.emit('modules.chats.getUnreadCount', {}); const chatIcon = components.get('chat/icon'); count = Math.max(0, count); chatIcon.toggleClass('fa-comment', count > 0) @@ -56,10 +71,9 @@ define('forum/header/chat', ['components', 'hooks'], function (components, hooks requireAndCall('onRoomRename', data); } - function requireAndCall(method, param) { - require(['chat'], function (chat) { - chat[method](param); - }); + async function requireAndCall(method, param) { + const chat = await app.require('chat'); + chat[method](param); } return chat; diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index ae435e72bd..277859ce3d 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -1,13 +1,13 @@ 'use strict'; define('autocomplete', ['api', 'alerts'], function (api, alerts) { - const module = {}; + const autocomplete = {}; const _default = { delay: 200, appendTo: null, }; - module.init = (params) => { + autocomplete.init = (params) => { const acParams = { ..._default, ...params }; const { input, onSelect } = acParams; app.loadJQueryUI(function () { @@ -23,14 +23,14 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) { }); }; - module.user = function (input, params, onSelect) { + autocomplete.user = function (input, params, onSelect) { if (typeof params === 'function') { onSelect = params; params = {}; } params = params || {}; - module.init({ + autocomplete.init({ input, onSelect, source: (request, response) => { @@ -69,8 +69,8 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) { }); }; - module.group = function (input, onSelect) { - module.init({ + autocomplete.group = function (input, onSelect) { + autocomplete.init({ input, onSelect, source: (request, response) => { @@ -96,8 +96,8 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) { }); }; - module.tag = function (input, onSelect) { - module.init({ + autocomplete.tag = function (input, onSelect) { + autocomplete.init({ input, onSelect, delay: 100, @@ -129,5 +129,5 @@ define('autocomplete', ['api', 'alerts'], function (api, alerts) { onselect(event, ui); } - return module; + return autocomplete; }); diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 54dca63971..d1b5a8ecd6 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -88,48 +88,49 @@ define('chat', [ if (err) { return alerts.error(err); } - - const rooms = data.rooms.filter(function (room) { - return room.teaser; + const rooms = data.rooms.map((room) => { + if (room && room.teaser) { + room.teaser.timeagoLong = $.timeago(new Date(parseInt(room.teaser.timestamp, 10))); + } + return room; }); - for (let i = 0; i < rooms.length; i += 1) { - rooms[i].teaser.timeagoLong = $.timeago(new Date(parseInt(rooms[i].teaser.timestamp, 10))); - } + translator.toggleTimeagoShorthand(async function () { + rooms.forEach((room) => { + if (room && room.teaser) { + room.teaser.timeago = $.timeago(new Date(parseInt(room.teaser.timestamp, 10))); + room.teaser.timeagoShort = room.teaser.timeago; + } + }); - translator.toggleTimeagoShorthand(function () { - for (let i = 0; i < rooms.length; i += 1) { - rooms[i].teaser.timeago = $.timeago(new Date(parseInt(rooms[i].teaser.timestamp, 10))); - rooms[i].teaser.timeagoShort = rooms[i].teaser.timeago; - } translator.toggleTimeagoShorthand(); - app.parseAndTranslate('partials/chats/dropdown', { rooms: rooms }, function (html) { - const listEl = chatsListEl.get(0); - - chatsListEl.find('*').not('.navigation-link').remove(); - chatsListEl.prepend(html); - chatsListEl.off('click').on('click', '[data-roomid]', function (ev) { - if (['.user-link', '.mark-read'].some(className => ev.target.closest(className))) { - return; - } - const roomId = $(this).attr('data-roomid'); - if (!ajaxify.currentPage.match(/^chats\//)) { - module.openChat(roomId); - } else { - ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId); - } - }); + const html = await app.parseAndTranslate('partials/chats/dropdown', { rooms: rooms }); + const listEl = chatsListEl.get(0); + + chatsListEl.find('*').not('.navigation-link').remove(); + chatsListEl.prepend(html); + chatsListEl.off('click').on('click', '[data-roomid]', function (ev) { + if (['.user-link', '.mark-read'].some(className => ev.target.closest(className))) { + return; + } + const roomId = $(this).attr('data-roomid'); + if (!ajaxify.currentPage.match(/^chats\//)) { + module.openChat(roomId); + } else { + ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId); + } + }); - listEl.removeEventListener('click', onMarkReadClicked); - listEl.addEventListener('click', onMarkReadClicked); + listEl.removeEventListener('click', onMarkReadClicked); + listEl.addEventListener('click', onMarkReadClicked); - $('[component="chats/mark-all-read"]').off('click').on('click', function () { - socket.emit('modules.chats.markAllRead', function (err) { - if (err) { - return alerts.error(err); - } + $('[component="chats/mark-all-read"]').off('click').on('click', async function () { + await socket.emit('modules.chats.markAllRead'); + if (ajaxify.data.template.chats) { + $('[component="chat/nav-wrapper"] [data-roomid]').each((i, el) => { + module.markChatElUnread($(el), false); }); - }); + } }); }); }); @@ -143,28 +144,51 @@ define('chat', [ e.stopPropagation(); const chatEl = e.target.closest('[data-roomid]'); - const state = !chatEl.classList.contains('unread'); + module.toggleReadState(chatEl); + } + + module.toggleReadState = function (chatEl) { + const state = !chatEl.classList.contains('unread'); // this is the new state const roomId = chatEl.getAttribute('data-roomid'); api[state ? 'put' : 'del'](`/chats/${roomId}/state`, {}).catch((err) => { alerts.error(err); // Revert on failure - chatEl.classList[state ? 'remove' : 'add']('unread'); - chatEl.querySelector('.unread').classList[state ? 'add' : 'remove']('hidden'); - chatEl.querySelector('.read').classList[!state ? 'add' : 'remove']('hidden'); + module.markChatElUnread($(chatEl), !(state === 1)); }); // Immediate feedback - chatEl.classList[state ? 'add' : 'remove']('unread'); - chatEl.querySelector('.unread').classList[!state ? 'add' : 'remove']('hidden'); - chatEl.querySelector('.read').classList[state ? 'add' : 'remove']('hidden'); - } + module.markChatElUnread($(chatEl), state === 1); + }; + + module.isFromBlockedUser = function (fromUid) { + return app.user.blocks.includes(parseInt(fromUid, 10)); + }; + + module.isLookingAtRoom = function (roomId) { + return ajaxify.data.template.chats && parseInt(ajaxify.data.roomId, 10) === parseInt(roomId, 10); + }; + + module.markChatElUnread = function (roomEl, unread) { + if (roomEl.length > 0) { + roomEl.toggleClass('unread', unread); + const markEl = roomEl.find('.mark-read'); + if (markEl.length) { + markEl.find('.read').toggleClass('hidden', unread); + markEl.find('.unread').toggleClass('hidden', !unread); + } + } + }; module.onChatMessageReceived = function (data) { - if (!newMessage) { - newMessage = data.self === 0; + if (app.user.blocks.includes(parseInt(data.fromUid, 10))) { + return; } if (module.modalExists(data.roomId)) { + data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0; + if (!newMessage) { + newMessage = data.self === 0; + } data.message.self = data.self; data.message.timestamp = Math.min(Date.now(), data.message.timetamp); data.message.timestampISO = utils.toISOString(data.message.timestamp); @@ -324,8 +348,9 @@ define('chat', [ 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.addMemberHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]')); + Chats.addManageHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]')); Chats.createAutoComplete(chatModal.attr('data-roomid'), chatModal.find('[component="chat/input"]')); @@ -381,10 +406,11 @@ define('chat', [ if (chatModal.attr('data-mobile')) { module.disableMobileBehaviour(chatModal); } + const roomId = chatModal.attr('data-roomid'); require(['forum/chats'], function (chats) { - chats.destroyAutoComplete(chatModal.attr('data-roomid')); + chats.destroyAutoComplete(roomId); }); - + socket.emit('modules.chats.leave', roomId); hooks.fire('action:chat.closed', { uuid: uuid, modal: chatModal, @@ -417,8 +443,9 @@ define('chat', [ taskbar.updateActive(uuid); ChatsMessages.scrollToBottom(chatModal.find('.chat-content')); module.focusInput(chatModal); - api.del(`/chats/${chatModal.attr('data-roomid')}/state`, {}); - + const roomId = chatModal.attr('data-roomid'); + api.del(`/chats/${roomId}/state`, {}); + socket.emit('modules.chats.enter', roomId); const env = utils.findBootstrapEnvironment(); if (env === 'xs' || env === 'sm') { module.enableMobileBehaviour(chatModal); diff --git a/public/src/sockets.js b/public/src/sockets.js index 6ffe0878c3..9325c0c595 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -159,9 +159,7 @@ app = window.app || {}; function onConnect() { if (!reconnecting) { hooks.fire('action:connected'); - } - - if (reconnecting) { + } else { const reconnectEl = $('#reconnect'); const reconnectAlert = $('#reconnect-alert'); @@ -188,6 +186,14 @@ app = window.app || {}; app.currentRoom = ''; app.enterRoom(current); } + if (ajaxify.data.template.chats) { + if (ajaxify.data.roomId) { + socket.emit('modules.chats.enter', ajaxify.data.roomId); + } + if (ajaxify.data.publicRooms) { + socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId)); + } + } } function onReconnecting() { diff --git a/src/api/chats.js b/src/api/chats.js index 2e7fe6dfd7..835117e235 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -2,6 +2,7 @@ const validator = require('validator'); +const db = require('../database'); const user = require('../user'); const meta = require('../meta'); const messaging = require('../messaging'); @@ -39,12 +40,20 @@ chatsAPI.create = async function (caller, data) { if (!data) { throw new Error('[[error:invalid-data]]'); } + const isPublic = data.type === 'public'; + const isAdmin = await user.isAdministrator(caller.uid); + if (isPublic && !isAdmin) { + throw new Error('[[error:no-privileges]]'); + } if (!data.uids || !Array.isArray(data.uids)) { throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`); } + if (!isPublic && !data.uids.length) { + throw new Error('[[error:no-users-selected]]'); + } await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); - const roomId = await messaging.newRoom(caller.uid, data.uids); + const roomId = await messaging.newRoom(caller.uid, data); return await messaging.getRoomData(roomId); }; @@ -78,20 +87,48 @@ chatsAPI.post = async (caller, data) => { return message; }; -chatsAPI.rename = async (caller, data) => { - if (!data || !data.roomId || !data.name) { +chatsAPI.update = async (caller, data) => { + if (!data || !data.roomId) { throw new Error('[[error:invalid-data]]'); } - await messaging.renameRoom(caller.uid, data.roomId, data.name); - const uids = await messaging.getUidsInRoom(data.roomId, 0, -1); - const eventData = { roomId: data.roomId, newName: validator.escape(String(data.name)) }; - socketHelpers.emitToUids('event:chats.roomRename', eventData, uids); + if (data.hasOwnProperty('name')) { + if (!data.name) { + throw new Error('[[error:invalid-data]]'); + } + await messaging.renameRoom(caller.uid, data.roomId, data.name); + const ioRoom = require('../socket.io').in(`chat_room_${data.roomId}`); + if (ioRoom) { + ioRoom.emit('event:chats.roomRename', { + roomId: data.roomId, + newName: validator.escape(String(data.name)), + }); + } + } + if (data.hasOwnProperty('groups')) { + const [roomData, isAdmin] = await Promise.all([ + messaging.getRoomData(data.roomId), + user.isAdministrator(caller.uid), + ]); + if (!roomData) { + throw new Error('[[error:invalid-data]]'); + } + if (roomData.public && isAdmin) { + await db.setObjectField(`chat:room:${data.roomId}`, 'groups', JSON.stringify(data.groups)); + } + } return messaging.loadRoom(caller.uid, { roomId: data.roomId, }); }; +chatsAPI.rename = async (caller, data) => { + if (!data || !data.roomId || !data.name) { + throw new Error('[[error:invalid-data]]'); + } + return await chatsAPI.update(caller, data); +}; + chatsAPI.mark = async (caller, data) => { if (!caller.uid || !data || !data.roomId) { throw new Error('[[error:invalid-data]]'); @@ -103,16 +140,19 @@ chatsAPI.mark = async (caller, data) => { await messaging.markRead(caller.uid, roomId); socketHelpers.emitToUids('event:chats.markedAsRead', { roomId: roomId }, [caller.uid]); - const uidsInRoom = await messaging.getUidsInRoom(roomId, 0, -1); - if (!uidsInRoom.includes(String(caller.uid))) { + const isUserInRoom = await messaging.isUserInRoom(caller.uid, roomId); + if (!isUserInRoom) { return; } - - // Mark notification read - const nids = uidsInRoom.filter(uid => parseInt(uid, 10) !== caller.uid) - .map(uid => `chat_${uid}_${roomId}`); - - await notifications.markReadMultiple(nids, caller.uid); + let chatNids = await db.getSortedSetScan({ + key: `uid:${caller.uid}:notifications:unread`, + match: `chat_*`, + }); + chatNids = chatNids.filter( + nid => nid && !nid.startsWith(`chat_${caller.uid}`) && nid.endsWith(`_${roomId}`) + ); + + await notifications.markReadMultiple(chatNids, caller.uid); await user.notifications.pushCount(caller.uid); } @@ -123,16 +163,18 @@ chatsAPI.mark = async (caller, data) => { }; chatsAPI.users = async (caller, data) => { + const start = data.hasOwnProperty('start') ? data.start : 0; + const stop = start + 39; const [isOwner, isUserInRoom, users] = await Promise.all([ messaging.isRoomOwner(caller.uid, data.roomId), messaging.isUserInRoom(caller.uid, data.roomId), - messaging.getUsersInRoom(data.roomId, 0, -1), + messaging.getUsersInRoom(data.roomId, start, stop), ]); if (!isUserInRoom) { throw new Error('[[error:no-privileges]]'); } users.forEach((user) => { - user.canKick = (parseInt(user.uid, 10) !== parseInt(caller.uid, 10)) && isOwner; + user.canKick = isOwner && (parseInt(user.uid, 10) !== parseInt(caller.uid, 10)); }); return { users }; }; @@ -145,10 +187,13 @@ chatsAPI.invite = async (caller, data) => { if (!data || !data.roomId) { throw new Error('[[error:invalid-data]]'); } - + const roomData = await messaging.getRoomData(data.roomId); + if (!roomData) { + throw new Error('[[error:invalid-data]]'); + } const userCount = await messaging.getUserCountInRoom(data.roomId); const maxUsers = meta.config.maximumUsersInChatRoom; - if (maxUsers && userCount >= maxUsers) { + if (!roomData.public && maxUsers && userCount >= maxUsers) { throw new Error('[[error:cant-add-more-users-to-chat-room]]'); } diff --git a/src/controllers/accounts/chats.js b/src/controllers/accounts/chats.js index 02c9b404cc..221e1f5fae 100644 --- a/src/controllers/accounts/chats.js +++ b/src/controllers/accounts/chats.js @@ -1,5 +1,6 @@ 'use strict'; +const db = require('../../database'); const messaging = require('../../messaging'); const meta = require('../../meta'); const user = require('../../user'); @@ -21,35 +22,45 @@ chatsController.get = async function (req, res, next) { if (!canChat) { return next(new Error('[[error:no-privileges]]')); } - const recentChats = await messaging.getRecentChats(req.uid, uid, 0, 29); - if (!recentChats) { - return next(); + + const payload = { + title: '[[pages:chats]]', + uid: uid, + userslug: req.params.userslug, + }; + const isSwitch = res.locals.isAPI && parseInt(req.query.switch, 10) === 1; + if (!isSwitch) { + const [recentChats, publicRooms, privateRoomCount] = await Promise.all([ + messaging.getRecentChats(req.uid, uid, 0, 29), + messaging.getPublicRooms(req.uid, uid), + db.sortedSetCard(`uid:${uid}:chat:rooms`), + ]); + if (!recentChats) { + return next(); + } + payload.rooms = recentChats.rooms; + payload.nextStart = recentChats.nextStart; + payload.publicRooms = publicRooms; + payload.privateRoomCount = privateRoomCount; } if (!req.params.roomid) { - return res.render('chats', { - rooms: recentChats.rooms, - uid: uid, - userslug: req.params.userslug, - nextStart: recentChats.nextStart, - allowed: true, - title: '[[pages:chats]]', - }); + return res.render('chats', payload); } + const room = await messaging.loadRoom(req.uid, { uid: uid, roomId: req.params.roomid }); if (!room) { return next(); } - room.rooms = recentChats.rooms; - room.nextStart = recentChats.nextStart; room.title = room.roomName || room.usernames || '[[pages:chats]]'; - room.uid = uid; - room.userslug = req.params.userslug; - + room.bodyClasses = ['chat-loaded']; room.canViewInfo = await privileges.global.can('view:users:info', uid); - res.render('chats', room); + res.render('chats', { + ...payload, + ...room, + }); }; chatsController.redirectToChat = async function (req, res, next) { diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index d2737ee314..1ef9756784 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -93,7 +93,7 @@ async function getPosts(callerUid, userData, setSuffix) { user.isModerator(callerUid, cids), privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid), ]); - const cidToIsMod = _.zipObject(cids, isModOfCids); + const isModOfCid = _.zipObject(cids, isModOfCids); const cidToCanSchedule = _.zipObject(cids, canSchedule); do { @@ -111,8 +111,12 @@ async function getPosts(callerUid, userData, setSuffix) { })); const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false }); postData.push(...p.filter( - p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] || - (p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted)) + p => p && p.topic && ( + isAdmin || + isModOfCid[p.topic.cid] || + (p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || + (!p.deleted && !p.topic.deleted) + ) )); } start += count; diff --git a/src/controllers/write/admin.js b/src/controllers/write/admin.js index b7ba39db10..84d25ac300 100644 --- a/src/controllers/write/admin.js +++ b/src/controllers/write/admin.js @@ -2,6 +2,8 @@ const api = require('../../api'); const helpers = require('../helpers'); +const messaging = require('../../messaging'); +const events = require('../../events'); const Admin = module.exports; @@ -29,6 +31,19 @@ Admin.getAnalyticsData = async (req, res) => { })); }; +Admin.chats = {}; + +Admin.chats.deleteRoom = async (req, res) => { + await messaging.deleteRooms([req.params.roomId]); + + events.log({ + type: 'chat-room-deleted', + uid: req.uid, + ip: req.ip, + }); + helpers.formatApiResponse(200, res); +}; + Admin.generateToken = async (req, res) => { const { uid, description } = req.body; const token = await api.utils.tokens.generate({ uid, description }); diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index fe7900a50a..e595ef1a44 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -39,6 +39,14 @@ Chats.post = async (req, res) => { helpers.formatApiResponse(200, res, messageObj); }; +Chats.update = async (req, res) => { + const payload = { ...req.body }; + payload.roomId = req.params.roomId; + const roomObj = await api.chats.update(req, payload); + + helpers.formatApiResponse(200, res, roomObj); +}; + Chats.rename = async (req, res) => { const roomObj = await api.chats.rename(req, { name: req.body.name, @@ -60,7 +68,8 @@ Chats.mark = async (req, res) => { Chats.users = async (req, res) => { const { roomId } = req.params; - const users = await api.chats.users(req, { roomId }); + const start = parseInt(req.query.start, 10) || 0; + const users = await api.chats.users(req, { roomId, start }); helpers.formatApiResponse(200, res, users); }; diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js index d5db6c3451..12859ed6a4 100644 --- a/src/database/mongo/sorted.js +++ b/src/database/mongo/sorted.js @@ -84,7 +84,9 @@ module.exports = function (module) { let result = []; async function doQuery(_key, fields, skip, limit) { - return await module.client.collection('objects').find({ ...query, ...{ _key: _key } }, { projection: fields }) + return await module.client.collection('objects').find({ + ...query, ...{ _key: _key }, + }, { projection: fields }) .sort({ score: sort }) .skip(skip) .limit(limit) diff --git a/src/events.js b/src/events.js index 637f53acf6..09c9062ae8 100644 --- a/src/events.js +++ b/src/events.js @@ -75,6 +75,7 @@ events.types = [ 'export:uploads', 'account-locked', 'getUsersCSV', + 'chat-room-deleted', // To add new types from plugins, just Array.push() to this array ]; diff --git a/src/groups/leave.js b/src/groups/leave.js index 14bfb2558c..32495232c9 100644 --- a/src/groups/leave.js +++ b/src/groups/leave.js @@ -1,9 +1,12 @@ 'use strict'; +const _ = require('lodash'); + const db = require('../database'); const user = require('../user'); const plugins = require('../plugins'); const cache = require('../cache'); +const messaging = require('../messaging'); module.exports = function (Groups) { Groups.leave = async function (groupNames, uid) { @@ -53,7 +56,10 @@ module.exports = function (Groups) { await Promise.all(promises); - await clearGroupTitleIfSet(groupsToLeave, uid); + await Promise.all([ + clearGroupTitleIfSet(groupsToLeave, uid), + leavePublicRooms(groupsToLeave, uid), + ]); plugins.hooks.fire('action:group.leave', { groupNames: groupsToLeave, @@ -61,6 +67,20 @@ module.exports = function (Groups) { }); }; + async function leavePublicRooms(groupNames, uid) { + const allRoomIds = await messaging.getPublicRoomIdsFromSet('chat:rooms:public:order'); + const allRoomData = await messaging.getRoomsData(allRoomIds); + const roomData = allRoomData.filter( + room => room && room.groups.some(group => groupNames.includes(group)) + ); + const isMemberOfAny = _.zipObject( + roomData.map(r => r.roomId), + await Promise.all(roomData.map(r => Groups.isMemberOfAny(uid, r.groups))) + ); + const roomIds = roomData.filter(r => isMemberOfAny[r.roomId]).map(r => r.roomId); + await messaging.leaveRooms(uid, roomIds); + } + async function clearGroupTitleIfSet(groupNames, uid) { groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName)); if (!groupNames.length) { diff --git a/src/groups/membership.js b/src/groups/membership.js index 66af1c1ed1..aa25719652 100644 --- a/src/groups/membership.js +++ b/src/groups/membership.js @@ -97,7 +97,7 @@ module.exports = function (Groups) { } Groups.isMemberOfAny = async function (uid, groups) { - if (!groups.length) { + if (!Array.isArray(groups) || !groups.length) { return false; } const isMembers = await Groups.isMemberOfGroups(uid, groups); diff --git a/src/groups/update.js b/src/groups/update.js index 56b541df27..ced53ef04e 100644 --- a/src/groups/update.js +++ b/src/groups/update.js @@ -189,6 +189,7 @@ module.exports = function (Groups) { await updateNavigationItems(oldName, newName); await updateWidgets(oldName, newName); await updateConfig(oldName, newName); + await updateChatRooms(oldName, newName); await db.setObject(`group:${oldName}`, { name: newName, slug: slugify(newName) }); await db.deleteObjectField('groupslug:groupname', group.slug); await db.setObjectField('groupslug:groupname', slugify(newName), newName); @@ -286,4 +287,18 @@ module.exports = function (Groups) { await meta.configs.set('groupsExemptFromMaintenanceMode', meta.config.groupsExemptFromMaintenanceMode); } } + + async function updateChatRooms(oldName, newName) { + const messaging = require('../messaging'); + const roomIds = await db.getSortedSetRange('chat:rooms:public', 0, -1); + const roomData = await messaging.getRoomsData(roomIds); + const bulkSet = []; + roomData.forEach((room) => { + if (room && room.public && Array.isArray(room.groups) && room.groups.includes(oldName)) { + room.groups.splice(room.groups.indexOf(oldName), 1, newName); + bulkSet.push([`chat:room:${room.roomId}`, { groups: JSON.stringify(room.groups) }]); + } + }); + await db.setObjectBulk(bulkSet); + } }; diff --git a/src/messaging/create.js b/src/messaging/create.js index d78a0afe4c..fa83a22c42 100644 --- a/src/messaging/create.js +++ b/src/messaging/create.js @@ -1,5 +1,7 @@ 'use strict'; +const _ = require('lodash'); + const meta = require('../meta'); const plugins = require('../plugins'); const db = require('../database'); @@ -34,13 +36,18 @@ module.exports = function (Messaging) { }; Messaging.addMessage = async (data) => { + const { uid, roomId } = data; + const roomData = await Messaging.getRoomData(roomId); + if (!roomData) { + throw new Error('[[error:no-room]]'); + } const mid = await db.incrObjectField('global', 'nextMid'); const timestamp = data.timestamp || Date.now(); let message = { content: String(data.content), timestamp: timestamp, - fromuid: data.uid, - roomId: data.roomId, + fromuid: uid, + roomId: roomId, deleted: 0, system: data.system || 0, }; @@ -51,24 +58,34 @@ module.exports = function (Messaging) { message = await plugins.hooks.fire('filter:messaging.save', message); await db.setObject(`message:${mid}`, message); - const isNewSet = await Messaging.isNewSet(data.uid, data.roomId, timestamp); - let uids = await db.getSortedSetRange(`chat:room:${data.roomId}:uids`, 0, -1); - uids = await user.blocks.filterUids(data.uid, uids); + const isNewSet = await Messaging.isNewSet(uid, roomId, timestamp); - await Promise.all([ - Messaging.addRoomToUsers(data.roomId, uids, timestamp), - Messaging.addMessageToUsers(data.roomId, uids, mid, timestamp), - Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), data.roomId), - ]); + const tasks = [ + Messaging.addMessageToRoom(roomId, mid, timestamp), + Messaging.markRead(uid, roomId), + ]; + if (roomData.public) { + tasks.push( + db.sortedSetAdd('chat:rooms:public:lastpost', timestamp, roomId) + ); + } else { + let uids = await Messaging.getUidsInRoom(roomId, 0, -1); + uids = await user.blocks.filterUids(uid, uids); + tasks.push( + Messaging.addRoomToUsers(roomId, uids, timestamp), + Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), roomId), + ); + } + await Promise.all(tasks); - const messages = await Messaging.getMessagesData([mid], data.uid, data.roomId, true); + const messages = await Messaging.getMessagesData([mid], uid, roomId, true); if (!messages || !messages[0]) { return null; } messages[0].newSet = isNewSet; - messages[0].mid = mid; - messages[0].roomId = data.roomId; + messages[0].mid = mid; // TODO: messageId is a duplicate + messages[0].roomId = roomId; plugins.hooks.fire('action:messaging.save', { message: messages[0], data: data }); return messages[0]; }; @@ -87,16 +104,11 @@ module.exports = function (Messaging) { if (!uids.length) { return; } - - const keys = uids.map(uid => `uid:${uid}:chat:rooms`); + const keys = _.uniq(uids).map(uid => `uid:${uid}:chat:rooms`); await db.sortedSetsAdd(keys, timestamp, roomId); }; - Messaging.addMessageToUsers = async (roomId, uids, mid, timestamp) => { - if (!uids.length) { - return; - } - const keys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`); - await db.sortedSetsAdd(keys, timestamp, mid); + Messaging.addMessageToRoom = async (roomId, mid, timestamp) => { + await db.sortedSetAdd(`chat:room:${roomId}:mids`, timestamp, mid); }; }; diff --git a/src/messaging/data.js b/src/messaging/data.js index 085081d8ec..b65f8737fb 100644 --- a/src/messaging/data.js +++ b/src/messaging/data.js @@ -78,13 +78,11 @@ module.exports = function (Messaging) { messages = await Promise.all(messages.map(async (message) => { if (message.system) { message.content = validator.escape(String(message.content)); - message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content)); return message; } const result = await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew); message.content = result; - message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(result)); return message; })); @@ -108,7 +106,7 @@ module.exports = function (Messaging) { }); } else if (messages.length === 1) { // For single messages, we don't know the context, so look up the previous message and compare - const key = `uid:${uid}:chat:room:${roomId}:mids`; + const key = `chat:room:${roomId}:mids`; const index = await db.sortedSetRank(key, messages[0].messageId); if (index > 0) { const mid = await db.getSortedSetRange(key, index - 1, index - 1); diff --git a/src/messaging/delete.js b/src/messaging/delete.js index 341bafda6b..c8070f7740 100644 --- a/src/messaging/delete.js +++ b/src/messaging/delete.js @@ -15,19 +15,12 @@ module.exports = function (Messaging) { await Messaging.setMessageField(mid, 'deleted', state); - const [uids, messages] = await Promise.all([ - Messaging.getUidsInRoom(roomId, 0, -1), - Messaging.getMessagesData([mid], uid, roomId, true), - ]); - - uids.forEach((_uid) => { - if (parseInt(_uid, 10) !== parseInt(uid, 10)) { - if (state === 1) { - sockets.in(`uid_${_uid}`).emit('event:chats.delete', mid); - } else if (state === 0) { - sockets.in(`uid_${_uid}`).emit('event:chats.restore', messages[0]); - } - } - }); + const messages = await Messaging.getMessagesData([mid], uid, roomId, true); + const ioRoom = sockets.in(`chat_room_${roomId}`); + if (state === 1 && ioRoom) { + ioRoom.emit('event:chats.delete', mid); + } else if (state === 0 && ioRoom) { + ioRoom.emit('event:chats.restore', messages[0]); + } } }; diff --git a/src/messaging/edit.js b/src/messaging/edit.js index 85cad068ab..d54b5dc587 100644 --- a/src/messaging/edit.js +++ b/src/messaging/edit.js @@ -27,15 +27,9 @@ module.exports = function (Messaging) { await Messaging.setMessageFields(mid, payload); // Propagate this change to users in the room - const [uids, messages] = await Promise.all([ - Messaging.getUidsInRoom(roomId, 0, -1), - Messaging.getMessagesData([mid], uid, roomId, true), - ]); - - uids.forEach((uid) => { - sockets.in(`uid_${uid}`).emit('event:chats.edit', { - messages: messages, - }); + const messages = await Messaging.getMessagesData([mid], uid, roomId, true); + sockets.in(`chat_room_${roomId}`).emit('event:chats.edit', { + messages: messages, }); }; diff --git a/src/messaging/index.js b/src/messaging/index.js index e532043a12..faede05c4e 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -1,15 +1,17 @@ 'use strict'; - +const _ = require('lodash'); const validator = require('validator'); const nconf = require('nconf'); const db = require('../database'); const user = require('../user'); +const groups = require('../groups'); const privileges = require('../privileges'); const plugins = require('../plugins'); const meta = require('../meta'); const utils = require('../utils'); const translator = require('../translator'); +const cache = require('../cache'); const relative_path = nconf.get('relative_path'); @@ -26,38 +28,50 @@ require('./notifications')(Messaging); Messaging.messageExists = async mid => db.exists(`message:${mid}`); Messaging.getMessages = async (params) => { + const { callerUid, uid, roomId } = params; const isNew = params.isNew || false; const start = params.hasOwnProperty('start') ? params.start : 0; const stop = parseInt(start, 10) + ((params.count || 50) - 1); - const indices = {}; - const ok = await canGet('filter:messaging.canGetMessages', params.callerUid, params.uid); + const ok = await canGet('filter:messaging.canGetMessages', callerUid, uid); if (!ok) { return; } - - const mids = await db.getSortedSetRevRange(`uid:${params.uid}:chat:room:${params.roomId}:mids`, start, stop); + const mids = await getMessageIds(roomId, uid, start, stop); if (!mids.length) { return []; } + const indices = {}; mids.forEach((mid, index) => { indices[mid] = start + index; }); mids.reverse(); - const messageData = await Messaging.getMessagesData(mids, params.uid, params.roomId, isNew); - messageData.forEach((messageData) => { - messageData.index = indices[messageData.messageId.toString()]; - messageData.isOwner = messageData.fromuid === parseInt(params.uid, 10); - if (messageData.deleted && !messageData.isOwner) { - messageData.content = '[[modules:chat.message-deleted]]'; - messageData.cleanedContent = messageData.content; + const messageData = await Messaging.getMessagesData(mids, uid, roomId, isNew); + messageData.forEach((msg) => { + msg.index = indices[msg.messageId.toString()]; + msg.isOwner = msg.fromuid === parseInt(uid, 10); + if (msg.deleted && !msg.isOwner) { + msg.content = `[[modules:chat.message-deleted]]
`; } }); return messageData; }; +async function getMessageIds(roomId, uid, start, stop) { + const isPublic = await db.getObjectField(`chat:room:${roomId}`, 'public'); + if (parseInt(isPublic, 10) === 1) { + return await db.getSortedSetRevRange( + `chat:room:${roomId}:mids`, start, stop, + ); + } + const userjoinTimestamp = await db.sortedSetScore(`chat:room:${roomId}:uids`, uid); + return await db.getSortedSetRevRangeByScore( + `chat:room:${roomId}:mids`, start, stop - start + 1, '+inf', userjoinTimestamp + ); +} + async function canGet(hook, callerUid, uid) { const data = await plugins.hooks.fire(hook, { callerUid: callerUid, @@ -85,7 +99,7 @@ Messaging.parse = async (message, fromuid, uid, roomId, isNew) => { }; Messaging.isNewSet = async (uid, roomId, timestamp) => { - const setKey = `uid:${uid}:chat:room:${roomId}:mids`; + const setKey = `chat:room:${roomId}:mids`; const messages = await db.getSortedSetRevRangeWithScores(setKey, 0, 0); if (messages && messages.length) { return parseInt(timestamp, 10) > parseInt(messages[0].score, 10) + Messaging.newMessageCutoff; @@ -93,6 +107,53 @@ Messaging.isNewSet = async (uid, roomId, timestamp) => { return true; }; +Messaging.getPublicRoomIdsFromSet = async function (set) { + const cacheKey = `${set}:all`; + let allRoomIds = cache.get(cacheKey); + if (allRoomIds === undefined) { + allRoomIds = await db.getSortedSetRange(set, 0, -1); + cache.set(cacheKey, allRoomIds); + } + return allRoomIds.slice(); +}; + +Messaging.getPublicRooms = async (callerUid, uid) => { + const ok = await canGet('filter:messaging.canGetPublicChats', callerUid, uid); + if (!ok) { + return null; + } + + const allRoomIds = await Messaging.getPublicRoomIdsFromSet('chat:rooms:public:order'); + const allRoomData = await Messaging.getRoomsData(allRoomIds); + const checks = await Promise.all( + allRoomData.map(room => groups.isMemberOfAny(uid, room && room.groups)) + ); + const roomData = allRoomData.filter((room, idx) => room && checks[idx]); + const roomIds = roomData.map(r => r.roomId); + const userReadTimestamps = await db.getObjectFields( + `uid:${uid}:chat:rooms:read`, + roomIds, + ); + + const maxUnread = 50; + const unreadCounts = await Promise.all(roomIds.map(async (roomId) => { + const cutoff = userReadTimestamps[roomId] || '-inf'; + const unreadMids = await db.getSortedSetRangeByScore( + `chat:room:${roomId}:mids`, 0, maxUnread + 1, cutoff, '+inf' + ); + return unreadMids.length; + })); + + roomData.forEach((r, idx) => { + const count = unreadCounts[idx]; + r.unreadCountText = count > maxUnread ? `${maxUnread}+` : String(count); + r.unreadCount = count; + r.unread = count > 0; + }); + + return roomData; +}; + Messaging.getRecentChats = async (callerUid, uid, start, stop) => { const ok = await canGet('filter:messaging.canGetRecentChats', callerUid, uid); if (!ok) { @@ -100,15 +161,29 @@ Messaging.getRecentChats = async (callerUid, uid, start, stop) => { } const roomIds = await db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, start, stop); + + async function getUsers(roomIds) { + const arrayOfUids = await Promise.all( + roomIds.map(roomId => Messaging.getUidsInRoom(roomId, 0, 9)) + ); + const uniqUids = _.uniq(_.flatten(arrayOfUids)).filter( + _uid => _uid && parseInt(_uid, 10) !== parseInt(uid, 10) + ); + const uidToUser = _.zipObject( + uniqUids, + await user.getUsersFields(uniqUids, [ + 'uid', 'username', 'userslug', 'picture', 'status', 'lastonline', + ]) + ); + return arrayOfUids.map(uids => uids.map(uid => uidToUser[uid])); + } + const results = await utils.promiseParallel({ roomData: Messaging.getRoomsData(roomIds), unread: db.isSortedSetMembers(`uid:${uid}:chat:rooms:unread`, roomIds), - users: Promise.all(roomIds.map(async (roomId) => { - let uids = await db.getSortedSetRevRange(`chat:room:${roomId}:uids`, 0, 9); - uids = uids.filter(_uid => _uid && parseInt(_uid, 10) !== parseInt(uid, 10)); - return await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); - })), - teasers: Promise.all(roomIds.map(async roomId => Messaging.getTeaser(uid, roomId))), + users: getUsers(roomIds), + teasers: Messaging.getTeasers(uid, roomIds), + settings: user.getSettings(uid), }); await Promise.all(results.roomData.map(async (room, index) => { @@ -126,7 +201,7 @@ Messaging.getRecentChats = async (callerUid, uid, start, stop) => { room.users = room.users.filter(user => user && parseInt(user.uid, 10)); room.lastUser = room.users[0]; room.usernames = Messaging.generateUsernames(room.users, uid); - room.chatWithMessage = await Messaging.generateChatWithMessage(room.users, uid); + room.chatWithMessage = await Messaging.generateChatWithMessage(room.users, uid, results.settings.userLang); } })); @@ -153,8 +228,8 @@ Messaging.generateUsernames = function (users, excludeUid) { return usernames.join(', '); }; -Messaging.generateChatWithMessage = async function (users, excludeUid) { - users = users.filter(u => u && parseInt(u.uid, 10) !== excludeUid); +Messaging.generateChatWithMessage = async function (users, callerUid, userLang) { + users = users.filter(u => u && parseInt(u.uid, 10) !== callerUid); const usernames = users.map(u => `${u.username}`); let compiled = ''; if (!users.length) { @@ -172,31 +247,48 @@ Messaging.generateChatWithMessage = async function (users, excludeUid) { usernames.join(', '), ); } - return utils.decodeHTMLEntities(await translator.translate(compiled)); + return utils.decodeHTMLEntities(await translator.translate(compiled, userLang)); }; Messaging.getTeaser = async (uid, roomId) => { - const mid = await Messaging.getLatestUndeletedMessage(uid, roomId); - if (!mid) { - return null; - } - const teaser = await Messaging.getMessageFields(mid, ['fromuid', 'content', 'timestamp']); - if (!teaser.fromuid) { - return null; - } - const blocked = await user.blocks.is(teaser.fromuid, uid); - if (blocked) { - return null; - } - - teaser.user = await user.getUserFields(teaser.fromuid, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); - if (teaser.content) { - teaser.content = utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content)); - teaser.content = validator.escape(String(teaser.content)); - } + const teasers = await Messaging.getTeasers(uid, [roomId]); + return teasers[0]; +}; - const payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser }); - return payload.teaser; +Messaging.getTeasers = async (uid, roomIds) => { + const mids = await Promise.all( + roomIds.map(roomId => Messaging.getLatestUndeletedMessage(uid, roomId)) + ); + const [teasers, blockedUids] = await Promise.all([ + Messaging.getMessagesFields(mids, ['fromuid', 'content', 'timestamp']), + user.blocks.list(uid), + ]); + const uids = _.uniq( + teasers.map(t => t && t.fromuid).filter(uid => uid && !blockedUids.includes(uid)) + ); + + const userMap = _.zipObject( + uids, + await user.getUsersFields(uids, [ + 'uid', 'username', 'userslug', 'picture', 'status', 'lastonline', + ]) + ); + + return await Promise.all(roomIds.map(async (roomId, idx) => { + const teaser = teasers[idx]; + if (!teaser || !teaser.fromuid) { + return null; + } + if (userMap[teaser.fromuid]) { + teaser.user = userMap[teaser.fromuid]; + } + teaser.content = validator.escape( + String(utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content))) + ); + teaser.roomId = roomId; + const payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser }); + return payload.teaser; + })); }; Messaging.getLatestUndeletedMessage = async (uid, roomId) => { @@ -207,7 +299,7 @@ Messaging.getLatestUndeletedMessage = async (uid, roomId) => { while (!done) { /* eslint-disable no-await-in-loop */ - mids = await db.getSortedSetRevRange(`uid:${uid}:chat:room:${roomId}:mids`, index, index); + mids = await getMessageIds(roomId, uid, index, index); if (mids.length) { const states = await Messaging.getMessageFields(mids[0], ['deleted', 'system']); done = !states.deleted && !states.system; @@ -337,8 +429,16 @@ Messaging.canViewMessage = async (mids, roomId, uid) => { mids = [mids]; single = true; } + const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1; + const [midTimestamps, userTimestamp] = await Promise.all([ + db.sortedSetScores(`chat:room:${roomId}:mids`, mids), + db.sortedSetScore(`chat:room:${roomId}:uids`, uid), + ]); + + const canView = midTimestamps.map( + midTimestamp => !!(midTimestamp && userTimestamp && (isPublic || userTimestamp <= midTimestamp)) + ); - const canView = await db.isSortedSetMembers(`uid:${uid}:chat:room:${roomId}:mids`, mids); return single ? canView.pop() : canView; }; diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index 6913c2d415..73cefdde92 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -2,39 +2,46 @@ const winston = require('winston'); -const user = require('../user'); +const batch = require('../batch'); +const db = require('../database'); const notifications = require('../notifications'); -const sockets = require('../socket.io'); +const io = require('../socket.io'); const plugins = require('../plugins'); const meta = require('../meta'); module.exports = function (Messaging) { - Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser - + // Only used to notify a user of a new chat message + Messaging.notifyQueue = {}; Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObj) => { - let uids = await Messaging.getUidsInRoom(roomId, 0, -1); - uids = await user.blocks.filterUids(fromUid, uids); + const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1; let data = { roomId: roomId, fromUid: fromUid, message: messageObj, - uids: uids, + public: isPublic, }; data = await plugins.hooks.fire('filter:messaging.notify', data); - if (!data || !data.uids || !data.uids.length) { + if (!data) { return; } - uids = data.uids; - uids.forEach((uid) => { - data.self = parseInt(uid, 10) === parseInt(fromUid, 10) ? 1 : 0; - Messaging.pushUnreadCount(uid); - sockets.in(`uid_${uid}`).emit('event:chats.receive', data); - }); - if (messageObj.system) { + // delivers full message to all online users in roomId + io.in(`chat_room_${roomId}`).emit('event:chats.receive', data); + + const unreadData = { roomId, fromUid, public: isPublic }; + if (isPublic && !messageObj.system) { + // delivers unread public msg to all online users on the chats page + io.in(`chat_room_public_${roomId}`).emit('event:chats.public.unread', unreadData); + } + if (messageObj.system || isPublic) { return; } + + // push unread count only for private rooms + const uids = await Messaging.getAllUidsInRoom(roomId); + Messaging.pushUnreadCount(uids, unreadData); + // Delayed notifications let queueObj = Messaging.notifyQueue[`${fromUid}:${roomId}`]; if (queueObj) { @@ -49,35 +56,35 @@ module.exports = function (Messaging) { queueObj.timeout = setTimeout(async () => { try { - await sendNotifications(fromUid, uids, roomId, queueObj.message); + await sendNotification(fromUid, roomId, queueObj.message); + delete Messaging.notifyQueue[`${fromUid}:${roomId}`]; } catch (err) { winston.error(`[messaging/notifications] Unabled to send notification\n${err.stack}`); } }, meta.config.notificationSendDelay * 1000); }; - async function sendNotifications(fromuid, uids, roomId, messageObj) { - const hasRead = await Messaging.hasRead(uids, roomId); - uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromuid, 10) !== parseInt(uid, 10)); - if (!uids.length) { - delete Messaging.notifyQueue[`${fromuid}:${roomId}`]; - return; - } - + async function sendNotification(fromUid, roomId, messageObj) { const { displayname } = messageObj.fromUser; - const isGroupChat = await Messaging.isGroupChat(roomId); const notification = await notifications.create({ type: isGroupChat ? 'new-group-chat' : 'new-chat', subject: `[[email:notif.chat.subject, ${displayname}]]`, bodyShort: `[[notifications:new_message_from, ${displayname}]]`, bodyLong: messageObj.content, - nid: `chat_${fromuid}_${roomId}`, - from: fromuid, + nid: `chat_${fromUid}_${roomId}`, + from: fromUid, path: `/chats/${messageObj.roomId}`, }); - delete Messaging.notifyQueue[`${fromuid}:${roomId}`]; - notifications.push(notification, uids); + await batch.processSortedSet(`chat:room:${roomId}:uids`, async (uids) => { + const hasRead = await Messaging.hasRead(uids, roomId); + uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromUid, 10) !== parseInt(uid, 10)); + + notifications.push(notification, uids); + }, { + batch: 500, + interval: 1000, + }); } }; diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index 49646d394a..54edfe3150 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -1,68 +1,185 @@ 'use strict'; +const _ = require('lodash'); const validator = require('validator'); +const winston = require('winston'); const db = require('../database'); const user = require('../user'); +const groups = require('../groups'); const plugins = require('../plugins'); const privileges = require('../privileges'); const meta = require('../meta'); +const cacheCreate = require('../cacheCreate'); + +const cache = cacheCreate({ + name: 'chat:room:uids', + max: 500, + ttl: 0, +}); + +const intFields = [ + 'roomId', 'timestamp', 'userCount', +]; module.exports = function (Messaging) { - Messaging.getRoomData = async (roomId) => { - const data = await db.getObject(`chat:room:${roomId}`); + Messaging.getRoomData = async (roomId, fields = []) => { + const data = await db.getObject(`chat:room:${roomId}`, fields); if (!data) { throw new Error('[[error:no-chat-room]]'); } - modifyRoomData([data]); + modifyRoomData([data], fields); return data; }; - Messaging.getRoomsData = async (roomIds) => { - const roomData = await db.getObjects(roomIds.map(roomId => `chat:room:${roomId}`)); - modifyRoomData(roomData); + Messaging.getRoomsData = async (roomIds, fields = []) => { + const roomData = await db.getObjects( + roomIds.map(roomId => `chat:room:${roomId}`), + fields + ); + modifyRoomData(roomData, fields); return roomData; }; - function modifyRoomData(rooms) { + function modifyRoomData(rooms, fields) { rooms.forEach((data) => { if (data) { - data.roomName = data.roomName || ''; - data.roomName = validator.escape(String(data.roomName)); + db.parseIntFields(data, intFields, fields); + data.roomName = validator.escape(String(data.roomName || '')); + data.public = parseInt(data.public, 10) === 1; if (data.hasOwnProperty('groupChat')) { data.groupChat = parseInt(data.groupChat, 10) === 1; } + + if (data.hasOwnProperty('groups')) { + try { + data.groups = JSON.parse(data.groups); + } catch (err) { + winston.error(err.stack); + data.groups = []; + } + } } }); } - Messaging.newRoom = async (uid, toUids) => { + Messaging.newRoom = async (uid, data) => { + // backwards compat. remove in 4.x + if (Array.isArray(data)) { // old usage second param used to be toUids + data = { uids: data }; + } const now = Date.now(); const roomId = await db.incrObjectField('global', 'nextChatRoomId'); const room = { owner: uid, roomId: roomId, + timestamp: now, }; + if (data.hasOwnProperty('roomName') && data.roomName) { + room.roomName = String(data.roomName); + } + if (Array.isArray(data.groups) && data.groups.length) { + room.groups = JSON.stringify(data.groups); + } + const isPublic = data.type === 'public'; + if (isPublic) { + room.public = 1; + } + await Promise.all([ db.setObject(`chat:room:${roomId}`, room), + db.sortedSetAdd('chat:rooms', now, roomId), db.sortedSetAdd(`chat:room:${roomId}:uids`, now, uid), ]); + await Promise.all([ - Messaging.addUsersToRoom(uid, toUids, roomId), - Messaging.addRoomToUsers(roomId, [uid].concat(toUids), now), + Messaging.addUsersToRoom(uid, data.uids, roomId), + isPublic ? + db.sortedSetAddBulk([ + ['chat:rooms:public', now, roomId], + ['chat:rooms:public:order', roomId, roomId], + ]) : + Messaging.addRoomToUsers(roomId, [uid].concat(data.uids), now), ]); - // chat owner should also get the user-join system message - await Messaging.addSystemMessage('user-join', uid, roomId); + + if (!isPublic) { + // chat owner should also get the user-join system message + await Messaging.addSystemMessage('user-join', uid, roomId); + } return roomId; }; - Messaging.isUserInRoom = async (uid, roomId) => { - const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid); - const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', { uid: uid, roomId: roomId, inRoom: inRoom }); - return data.inRoom; + Messaging.deleteRooms = async (roomIds) => { + if (!roomIds) { + throw new Error('[[error:invalid-data]]'); + } + + if (!Array.isArray(roomIds)) { + roomIds = [roomIds]; + } + + await Promise.all(roomIds.map(async (roomId) => { + const uids = await db.getSortedSetMembers(`chat:room:${roomId}:uids`); + const keys = uids + .map(uid => `uid:${uid}:chat:rooms`) + .concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`)); + + await Promise.all([ + db.sortedSetRemove(`chat:room:${roomId}:uids`, uids), + db.sortedSetsRemove(keys, roomId), + ]); + })); + await Promise.all([ + db.deleteAll(roomIds.map(id => `chat:room:${id}`)), + db.sortedSetRemove('chat:rooms', roomIds), + db.sortedSetRemove('chat:rooms:public', roomIds), + ]); + }; + + Messaging.isUserInRoom = async (uid, roomIds) => { + let single = false; + if (!Array.isArray(roomIds)) { + roomIds = [roomIds]; + single = true; + } + const inRooms = await db.isMemberOfSortedSets( + roomIds.map(id => `chat:room:${id}:uids`), + uid + ); + + const data = await Promise.all(roomIds.map(async (roomId, idx) => { + const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', { + uid: uid, + roomId: roomId, + inRoom: inRooms[idx], + }); + return data.inRoom; + })); + return single ? data.pop() : data; + }; + + Messaging.isUsersInRoom = async (uids, roomId) => { + let single = false; + if (!Array.isArray(uids)) { + uids = [uids]; + single = true; + } + + const inRooms = await db.isSortedSetMembers( + `chat:room:${roomId}:uids`, + uids, + ); + + const data = await plugins.hooks.fire('filter:messaging.isUsersInRoom', { + uids: uids, + roomId: roomId, + inRooms: inRooms, + }); + + return single ? data.inRooms.pop() : data.inRooms; }; Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}:uids`); @@ -84,7 +201,12 @@ module.exports = function (Messaging) { return isArray ? result : result[0]; }; + Messaging.isRoomPublic = async function (roomId) { + return parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1; + }; + Messaging.addUsersToRoom = async function (uid, uids, roomId) { + uids = _.uniq(uids); const inRoom = await Messaging.isUserInRoom(uid, roomId); const payload = await plugins.hooks.fire('filter:messaging.addUsersToRoom', { uid, uids, roomId, inRoom }); @@ -92,13 +214,17 @@ module.exports = function (Messaging) { throw new Error('[[error:cant-add-users-to-chat-room]]'); } - const now = Date.now(); - const timestamps = payload.uids.map(() => now); - await db.sortedSetAdd(`chat:room:${payload.roomId}:uids`, timestamps, payload.uids); - await updateGroupChatField([payload.roomId]); - await Promise.all(payload.uids.map(uid => Messaging.addSystemMessage('user-join', uid, payload.roomId))); + await addUidsToRoom(payload.uids, roomId); }; + async function addUidsToRoom(uids, roomId) { + const now = Date.now(); + const timestamps = uids.map(() => now); + await db.sortedSetAdd(`chat:room:${roomId}:uids`, timestamps, uids); + await updateUserCount([roomId]); + await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-join', uid, roomId))); + } + Messaging.removeUsersFromRoom = async (uid, uids, roomId) => { const [isOwner, userCount] = await Promise.all([ Messaging.isRoomOwner(uid, roomId), @@ -117,14 +243,16 @@ module.exports = function (Messaging) { return (await Messaging.getRoomData(roomId)).groupChat; }; - async function updateGroupChatField(roomIds) { + async function updateUserCount(roomIds) { const userCounts = await db.sortedSetsCard(roomIds.map(roomId => `chat:room:${roomId}:uids`)); + const countMap = _.zipObject(roomIds, userCounts); const groupChats = roomIds.filter((roomId, index) => userCounts[index] > 2); const privateChats = roomIds.filter((roomId, index) => userCounts[index] <= 2); await db.setObjectBulk([ - ...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1 }]), - ...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0 }]), + ...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1, userCount: countMap[id] }]), + ...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0, userCount: countMap[id] }]), ]); + cache.del(roomIds.map(id => `chat:room:${id}:users`)); } Messaging.leaveRoom = async (uids, roomId) => { @@ -142,7 +270,7 @@ module.exports = function (Messaging) { await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-leave', uid, roomId))); await updateOwner(roomId); - await updateGroupChatField([roomId]); + await updateUserCount([roomId]); }; Messaging.leaveRooms = async (uid, roomIds) => { @@ -162,7 +290,7 @@ module.exports = function (Messaging) { roomIds.map(roomId => updateOwner(roomId)) .concat(roomIds.map(roomId => Messaging.addSystemMessage('user-leave', uid, roomId))) ); - await updateGroupChatField(roomIds); + await updateUserCount(roomIds); }; async function updateOwner(roomId) { @@ -171,7 +299,18 @@ module.exports = function (Messaging) { await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner); } - Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRevRange(`chat:room:${roomId}:uids`, start, stop); + Messaging.getAllUidsInRoom = async function (roomId) { + const cacheKey = `chat:room:${roomId}:users`; + let uids = cache.get(cacheKey); + if (uids !== undefined) { + return uids; + } + uids = await Messaging.getUidsInRoom(roomId, 0, -1); + cache.set(cacheKey, uids); + return uids; + }; + + Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRange(`chat:room:${roomId}:uids`, start, stop); Messaging.getUsersInRoom = async (roomId, start, stop) => { const uids = await Messaging.getUidsInRoom(roomId, start, stop); @@ -181,6 +320,7 @@ module.exports = function (Messaging) { ]); return users.map((user, index) => { + user.index = start + index; user.isOwner = isOwners[index]; return user; }); @@ -221,40 +361,55 @@ module.exports = function (Messaging) { }; Messaging.loadRoom = async (uid, data) => { - const canChat = await privileges.global.can('chat', uid); + const { roomId } = data; + const [room, inRoom, canChat] = await Promise.all([ + Messaging.getRoomData(roomId), + Messaging.isUserInRoom(uid, roomId), + privileges.global.can('chat', uid), + ]); + if (!canChat) { throw new Error('[[error:no-privileges]]'); } - const inRoom = await Messaging.isUserInRoom(uid, data.roomId); - if (!inRoom) { + if (!room || + (!room.public && !inRoom) || + (room.public && !(await groups.isMemberOfAny(uid, room.groups))) + ) { return null; } - const [room, canReply, users, messages, isAdminOrGlobalMod, isOwner] = await Promise.all([ - Messaging.getRoomData(data.roomId), - Messaging.canReply(data.roomId, uid), - Messaging.getUsersInRoom(data.roomId, 0, -1), + // add user to public room onload + if (room.public && !inRoom) { + await addUidsToRoom([uid], roomId); + } + + const [canReply, users, messages, isAdmin, isGlobalMod, settings, isOwner] = await Promise.all([ + Messaging.canReply(roomId, uid), + Messaging.getUsersInRoom(roomId, 0, 39), Messaging.getMessages({ callerUid: uid, uid: data.uid || uid, - roomId: data.roomId, + roomId: roomId, isNew: false, }), - user.isAdminOrGlobalMod(uid), - Messaging.isRoomOwner(uid, data.roomId), + user.isAdministrator(uid), + user.isGlobalModerator(uid), + user.getSettings(uid), + Messaging.isRoomOwner(uid, roomId), ]); room.messages = messages; room.isOwner = isOwner; - room.users = users.filter(user => user && parseInt(user.uid, 10) && parseInt(user.uid, 10) !== parseInt(uid, 10)); + room.users = users; room.canReply = canReply; room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : users.length > 2; room.usernames = Messaging.generateUsernames(users, uid); - room.chatWithMessage = await Messaging.generateChatWithMessage(users, uid); + room.chatWithMessage = await Messaging.generateChatWithMessage(users, uid, settings.userLang); room.maximumUsersInChatRoom = meta.config.maximumUsersInChatRoom; room.maximumChatMessageLength = meta.config.maximumChatMessageLength; room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2; - room.isAdminOrGlobalMod = isAdminOrGlobalMod; + room.isAdminOrGlobalMod = isAdmin || isGlobalMod; + room.isAdmin = isAdmin; const payload = await plugins.hooks.fire('filter:messaging.loadRoom', { uid, data, room }); return payload.room; diff --git a/src/messaging/unread.js b/src/messaging/unread.js index 3a31595070..8b98ec279d 100644 --- a/src/messaging/unread.js +++ b/src/messaging/unread.js @@ -1,27 +1,35 @@ 'use strict'; const db = require('../database'); -const sockets = require('../socket.io'); +const io = require('../socket.io'); module.exports = function (Messaging) { Messaging.getUnreadCount = async (uid) => { - if (parseInt(uid, 10) <= 0) { + if (!(parseInt(uid, 10) > 0)) { return 0; } return await db.sortedSetCard(`uid:${uid}:chat:rooms:unread`); }; - Messaging.pushUnreadCount = async (uid) => { - if (parseInt(uid, 10) <= 0) { + Messaging.pushUnreadCount = async (uids, data = null) => { + if (!Array.isArray(uids)) { + uids = [uids]; + } + uids = uids.filter(uid => parseInt(uid, 10) > 0); + if (!uids.length) { return; } - const unreadCount = await Messaging.getUnreadCount(uid); - sockets.in(`uid_${uid}`).emit('event:unread.updateChatCount', unreadCount); + uids.forEach((uid) => { + io.in(`uid_${uid}`).emit('event:unread.updateChatCount', data); + }); }; Messaging.markRead = async (uid, roomId) => { - await db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId); + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId), + db.setObjectField(`uid:${uid}:chat:rooms:read`, roomId, Date.now()), + ]); }; Messaging.hasRead = async (uids, roomId) => { @@ -42,6 +50,6 @@ module.exports = function (Messaging) { return; } const keys = uids.map(uid => `uid:${uid}:chat:rooms:unread`); - return await db.sortedSetsAdd(keys, Date.now(), roomId); + await db.sortedSetsAdd(keys, Date.now(), roomId); }; }; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index f91fde93f4..553114f870 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -113,8 +113,8 @@ Assert.room = helpers.try(async (req, res, next) => { } const [exists, inRoom] = await Promise.all([ - await messaging.roomExists(req.params.roomId), - await messaging.isUserInRoom(req.uid, req.params.roomId), + messaging.roomExists(req.params.roomId), + messaging.isUserInRoom(req.uid, req.params.roomId), ]); if (!exists) { diff --git a/src/middleware/render.js b/src/middleware/render.js index 71673b3052..ac7121d6f8 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -169,6 +169,7 @@ module.exports = function (middleware) { isGlobalMod: user.isGlobalModerator(req.uid), isModerator: user.isModeratorOfAnyCategory(req.uid), privileges: privileges.global.get(req.uid), + blocks: user.blocks.list(req.uid), user: user.getUserData(req.uid), isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid), languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang), @@ -190,6 +191,7 @@ module.exports = function (middleware) { results.user.isGlobalMod = results.isGlobalMod; results.user.isMod = !!results.isModerator; results.user.privileges = results.privileges; + results.user.blocks = results.blocks; results.user.timeagoCode = results.timeagoCode; results.user[results.user.status] = true; results.user.lastRoomId = results.roomIds.length ? results.roomIds[0] : null; diff --git a/src/routes/write/admin.js b/src/routes/write/admin.js index 2571b8dd01..593e9ce123 100644 --- a/src/routes/write/admin.js +++ b/src/routes/write/admin.js @@ -15,6 +15,8 @@ module.exports = function () { setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys); setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData); + setupApiRoute(router, 'delete', '/chats/:roomId', [...middlewares, middleware.assert.room], controllers.write.admin.chats.deleteRoom); + setupApiRoute(router, 'post', '/tokens', [...middlewares], controllers.write.admin.generateToken); setupApiRoute(router, 'get', '/tokens/:token', [...middlewares], controllers.write.admin.getToken); setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken); diff --git a/src/routes/write/chats.js b/src/routes/write/chats.js index a92db701f7..3334cb377f 100644 --- a/src/routes/write/chats.js +++ b/src/routes/write/chats.js @@ -16,8 +16,7 @@ module.exports = function () { setupApiRoute(router, 'head', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.exists); setupApiRoute(router, 'get', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.get); setupApiRoute(router, 'post', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['message'])], controllers.write.chats.post); - setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['name'])], controllers.write.chats.rename); - // no route for room deletion, noted here just in case... + setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.update); setupApiRoute(router, 'put', '/:roomId/state', [...middlewares, middleware.assert.room], controllers.write.chats.mark); setupApiRoute(router, 'delete', '/:roomId/state', [...middlewares, middleware.assert.room], controllers.write.chats.mark); diff --git a/src/socket.io/admin/rooms.js b/src/socket.io/admin/rooms.js index f34f9b2c13..c426d0c7d6 100644 --- a/src/socket.io/admin/rooms.js +++ b/src/socket.io/admin/rooms.js @@ -20,7 +20,6 @@ SocketRooms.getAll = async function () { totals.onlineGuestCount = 0; totals.onlineRegisteredCount = 0; totals.socketCount = sockets.length; - totals.topics = {}; totals.topTenTopics = []; totals.users = { categories: 0, diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js index 969a0c07e8..d34eec366e 100644 --- a/src/socket.io/groups.js +++ b/src/socket.io/groups.js @@ -65,6 +65,17 @@ SocketGroups.loadMoreMembers = async (socket, data) => { }; }; +SocketGroups.getChatGroups = async (socket) => { + const isAdmin = await user.isAdministrator(socket.uid); + if (!isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + const allGroups = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); + const groupsList = allGroups.filter(g => !groups.ephemeralGroups.includes(g.name)); + groupsList.sort((a, b) => b.system - a.system); + return groupsList.map(g => ({ name: g.name, displayName: g.displayName })); +}; + async function canSearchMembers(uid, groupName) { const [isHidden, isMember, hasAdminPrivilege, isGlobalMod, viewGroups] = await Promise.all([ groups.isHidden(groupName), diff --git a/src/socket.io/meta.js b/src/socket.io/meta.js index 68230bd6f0..e62ae0e356 100644 --- a/src/socket.io/meta.js +++ b/src/socket.io/meta.js @@ -4,9 +4,8 @@ const user = require('../user'); const topics = require('../topics'); -const SocketMeta = { - rooms: {}, -}; +const SocketMeta = module.exports; +SocketMeta.rooms = {}; SocketMeta.reconnected = function (socket, data, callback) { callback = callback || function () {}; @@ -19,13 +18,13 @@ SocketMeta.reconnected = function (socket, data, callback) { /* Rooms */ -SocketMeta.rooms.enter = function (socket, data, callback) { +SocketMeta.rooms.enter = async function (socket, data) { if (!socket.uid) { - return callback(); + return; } if (!data) { - return callback(new Error('[[error:invalid-data]]')); + throw new Error('[[error:invalid-data]]'); } if (data.enter) { @@ -33,7 +32,11 @@ SocketMeta.rooms.enter = function (socket, data, callback) { } if (data.enter && data.enter.startsWith('uid_') && data.enter !== `uid_${socket.uid}`) { - return callback(new Error('[[error:not-allowed]]')); + throw new Error('[[error:not-allowed]]'); + } + + if (data.enter && data.enter.startsWith('chat_')) { + throw new Error('[[error:not-allowed]]'); } leaveCurrentRoom(socket); @@ -42,15 +45,13 @@ SocketMeta.rooms.enter = function (socket, data, callback) { socket.join(data.enter); socket.currentRoom = data.enter; } - callback(); }; -SocketMeta.rooms.leaveCurrent = function (socket, data, callback) { +SocketMeta.rooms.leaveCurrent = async function (socket) { if (!socket.uid || !socket.currentRoom) { - return callback(); + return; } leaveCurrentRoom(socket); - callback(); }; function leaveCurrentRoom(socket) { @@ -60,4 +61,4 @@ function leaveCurrentRoom(socket) { } } -module.exports = SocketMeta; +require('../promisify')(SocketMeta); diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 679a2516ec..f98b7052a8 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -1,5 +1,7 @@ 'use strict'; +const _ = require('lodash'); + const db = require('../database'); const Messaging = require('../messaging'); const utils = require('../utils'); @@ -18,13 +20,13 @@ SocketModules.chats.getRaw = async function (socket, data) { throw new Error('[[error:invalid-data]]'); } const roomId = await Messaging.getMessageField(data.mid, 'roomId'); - const [isAdmin, hasMessage, inRoom] = await Promise.all([ + const [isAdmin, canViewMessage, inRoom] = await Promise.all([ user.isAdministrator(socket.uid), - db.isSortedSetMember(`uid:${socket.uid}:chat:room:${roomId}:mids`, data.mid), + Messaging.canViewMessage(data.mid, roomId, socket.uid), Messaging.isUserInRoom(socket.uid, roomId), ]); - if (!isAdmin && (!inRoom || !hasMessage)) { + if (!isAdmin && (!inRoom || !canViewMessage)) { throw new Error('[[error:not-allowed]]'); } @@ -70,4 +72,107 @@ SocketModules.chats.getIP = async function (socket, mid) { return await Messaging.getMessageField(mid, 'ip'); }; +SocketModules.chats.getUnreadCount = async function (socket) { + return await Messaging.getUnreadCount(socket.uid); +}; + +SocketModules.chats.enter = async function (socket, roomIds) { + await joinLeave(socket, roomIds, 'join'); +}; + +SocketModules.chats.leave = async function (socket, roomIds) { + await joinLeave(socket, roomIds, 'leave'); +}; + +SocketModules.chats.enterPublic = async function (socket, roomIds) { + await joinLeave(socket, roomIds, 'join', 'chat_room_public'); +}; + +SocketModules.chats.leavePublic = async function (socket, roomIds) { + await joinLeave(socket, roomIds, 'leave', 'chat_room_public'); +}; + +async function joinLeave(socket, roomIds, method, prefix = 'chat_room') { + if (!(socket.uid > 0)) { + throw new Error('[[error:not-allowed]]'); + } + if (!Array.isArray(roomIds)) { + roomIds = [roomIds]; + } + if (roomIds.length) { + const [isAdmin, inRooms, roomData] = await Promise.all([ + user.isAdministrator(socket.uid), + Messaging.isUserInRoom(socket.uid, roomIds), + Messaging.getRoomsData(roomIds, ['public', 'groups']), + ]); + + await Promise.all(roomIds.map(async (roomId, idx) => { + const isPublic = roomData[idx] && roomData[idx].public; + const groups = roomData[idx] && roomData[idx].groups; + if (isAdmin || (inRooms[idx] && (!isPublic || await groups.isMemberOfAny(socket.uid, groups)))) { + socket[method](`${prefix}_${roomId}`); + } + })); + } +} + +SocketModules.chats.sortPublicRooms = async function (socket, data) { + if (!data || !Array.isArray(data.scores) || !Array.isArray(data.roomIds)) { + throw new Error('[[error:invalid-data]]'); + } + const isAdmin = await user.isAdministrator(socket.uid); + if (!isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + await db.sortedSetAdd(`chat:rooms:public:order`, data.scores, data.roomIds); +}; + +SocketModules.chats.searchMembers = async function (socket, data) { + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + const [isAdmin, inRoom, isRoomOwner] = await Promise.all([ + user.isAdministrator(socket.uid), + Messaging.isUserInRoom(socket.uid, data.roomId), + Messaging.isRoomOwner(socket.uid, data.roomId), + ]); + + if (!isAdmin && !inRoom) { + throw new Error('[[error:no-privileges]]'); + } + + const results = await user.search({ + query: data.username, + paginate: false, + hardCap: -1, + }); + + const { users } = results; + const foundUids = users.map(user => user && user.uid); + const isUidInRoom = _.zipObject( + foundUids, + await Messaging.isUsersInRoom(foundUids, data.roomId) + ); + + const roomUsers = users.filter(user => isUidInRoom[user.uid]); + const isOwners = await Messaging.isRoomOwner(roomUsers.map(u => u.uid), data.roomId); + + roomUsers.forEach((user, index) => { + if (user) { + user.isOwner = isOwners[index]; + user.canKick = isRoomOwner && (parseInt(user.uid, 10) !== parseInt(socket.uid, 10)); + } + }); + + roomUsers.sort((a, b) => { + if (a.isOwner && !b.isOwner) { + return -1; + } else if (!a.isOwner && b.isOwner) { + return 1; + } + return 0; + }); + return { users: roomUsers }; +}; + require('../promisify')(SocketModules); diff --git a/src/upgrades/3.3.0/chat_room_refactor.js b/src/upgrades/3.3.0/chat_room_refactor.js new file mode 100644 index 0000000000..9b3337fc3c --- /dev/null +++ b/src/upgrades/3.3.0/chat_room_refactor.js @@ -0,0 +1,64 @@ +'use strict'; + + +const _ = require('lodash'); + +const db = require('../../database'); +const batch = require('../../batch'); + + +module.exports = { + name: 'Update chat messages to add roomId field', + timestamp: Date.UTC(2023, 6, 2), + method: async function () { + const { progress } = this; + + const nextChatRoomId = await db.getObjectField('global', 'nextChatRoomId'); + const allRoomIds = []; + for (let i = 1; i <= nextChatRoomId; i++) { + allRoomIds.push(i); + } + progress.total = allRoomIds.length; + await batch.processArray(allRoomIds, async (roomIds) => { + progress.incr(roomIds.length); + await Promise.all(roomIds.map(async (roomId) => { + const [uids, roomData] = await Promise.all([ + db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, -1), + db.getObject(`chat:room:${roomId}`), + ]); + + if (!uids.length && !roomData) { + return; + } + if (roomData && roomData.owner && !uids.includes(String(roomData.owner))) { + uids.push(roomData.owner); + } + const userKeys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`); + const mids = await db.getSortedSetsMembers(userKeys); + const uniqMids = _.uniq(_.flatten(mids)); + let messageData = await db.getObjects(uniqMids.map(mid => `message:${mid}`)); + messageData.forEach((m, idx) => { + if (m) { + m.mid = parseInt(uniqMids[idx], 10); + } + }); + messageData = messageData.filter(Boolean); + + const bulkSet = messageData.map( + msg => [`message:${msg.mid}`, { roomId: roomId }] + ); + + await db.setObjectBulk(bulkSet); + await db.setObjectField(`chat:room:${roomId}`, 'userCount', uids.length); + await db.sortedSetAdd( + `chat:room:${roomId}:mids`, + messageData.map(m => m.timestamp), + messageData.map(m => m.mid), + ); + await db.deleteAll(userKeys); + })); + }, { + batch: 500, + }); + }, +}; diff --git a/src/upgrades/3.3.0/save_rooms_zset.js b/src/upgrades/3.3.0/save_rooms_zset.js new file mode 100644 index 0000000000..9c75362a50 --- /dev/null +++ b/src/upgrades/3.3.0/save_rooms_zset.js @@ -0,0 +1,42 @@ +'use strict'; + +const db = require('../../database'); +const batch = require('../../batch'); + +module.exports = { + name: 'Store list of chat rooms', + timestamp: Date.UTC(2023, 6, 3), + method: async function () { + const { progress } = this; + const lastRoomId = await db.getObjectField('global', 'nextChatRoomId'); + const allRoomIds = []; + for (let x = 1; x <= lastRoomId; x++) { + allRoomIds.push(x); + } + const users = await db.getSortedSetRangeWithScores(`users:joindate`, 0, 0); + const timestamp = users.length ? users[0].score : Date.now(); + progress.total = allRoomIds.length; + + await batch.processArray(allRoomIds, async (roomIds) => { + progress.incr(roomIds.length); + const keys = roomIds.map(id => `chat:room:${id}`); + const exists = await db.exists(keys); + roomIds = roomIds.filter((_, idx) => exists[idx]); + // get timestamp from uids, if no users use the timestamp of first user + const arrayOfUids = await Promise.all( + roomIds.map(roomId => db.getSortedSetRangeWithScores(`chat:room:${roomId}:uids`, 0, 0)) + ); + + const timestamps = roomIds.map( + (id, idx) => (arrayOfUids[idx].length ? (arrayOfUids[idx][0].score || timestamp) : timestamp) + ); + + await db.sortedSetAdd('chat:rooms', timestamps, roomIds); + await db.setObjectBulk( + roomIds.map((id, idx) => ([`chat:room:${id}`, { timestamp: timestamps[idx] }])) + ); + }, { + batch: 500, + }); + }, +}; diff --git a/src/user/delete.js b/src/user/delete.js index 4cc574c4ff..1362df1621 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -116,7 +116,9 @@ module.exports = function (User) { `user:${uid}:emails`, `uid:${uid}:topics`, `uid:${uid}:posts`, `uid:${uid}:chats`, `uid:${uid}:chats:unread`, - `uid:${uid}:chat:rooms`, `uid:${uid}:chat:rooms:unread`, + `uid:${uid}:chat:rooms`, + `uid:${uid}:chat:rooms:unread`, + `uid:${uid}:chat:rooms:read`, `uid:${uid}:upvote`, `uid:${uid}:downvote`, `uid:${uid}:flag:pids`, `uid:${uid}:sessions`, `uid:${uid}:sessionUUID:sessionId`, @@ -168,13 +170,10 @@ module.exports = function (User) { } async function deleteChats(uid) { - const roomIds = await db.getSortedSetRange(`uid:${uid}:chat:rooms`, 0, -1); - const userKeys = roomIds.map(roomId => `uid:${uid}:chat:room:${roomId}:mids`); - - await Promise.all([ - messaging.leaveRooms(uid, roomIds), - db.deleteAll(userKeys), - ]); + const roomIds = await db.getSortedSetRange([ + `uid:${uid}:chat:rooms`, `chat:rooms:public`, + ], 0, -1); + await messaging.leaveRooms(uid, roomIds); } async function deleteUserIps(uid) { diff --git a/src/user/jobs/export-profile.js b/src/user/jobs/export-profile.js index 49c18550ca..27177d112d 100644 --- a/src/user/jobs/export-profile.js +++ b/src/user/jobs/export-profile.js @@ -89,7 +89,7 @@ process.on('message', async (msg) => { async function getRoomMessages(uid, roomId) { const batch = require('../../batch'); let data = []; - await batch.processSortedSet(`uid:${uid}:chat:room:${roomId}:mids`, async (mids) => { + await batch.processSortedSet(`chat:room:${roomId}:mids`, async (mids) => { const messageData = await db.getObjects(mids.map(mid => `message:${mid}`)); data = data.concat( messageData diff --git a/src/views/modals/create-room.tpl b/src/views/modals/create-room.tpl new file mode 100644 index 0000000000..dcb470cd6e --- /dev/null +++ b/src/views/modals/create-room.tpl @@ -0,0 +1,45 @@ ++ [[modules:chat.public.groups-help]] +
+[[modules:chat.add-user-help]]
[[modules:chat.message-deleted]]
', msg.content); }); }); diff --git a/test/user.js b/test/user.js index 066f6f13ae..660d35b805 100644 --- a/test/user.js +++ b/test/user.js @@ -574,7 +574,7 @@ describe('User', () => { const socketModules = require('../src/socket.io/modules'); const uid1 = await User.create({ username: 'chatuserdelete1' }); const uid2 = await User.create({ username: 'chatuserdelete2' }); - const roomId = await messaging.newRoom(uid1, [uid2]); + const roomId = await messaging.newRoom(uid1, { uids: [uid2] }); await messaging.addMessage({ uid: uid1, content: 'hello',