From 61f036ce1d860fcd58055fcbc8bef6f1244f08ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 21 Jul 2023 15:31:34 -0400 Subject: [PATCH] Chat notifs (#11832) * first part of chat notifs * moved default notif to manage page * spec * notifs * delete settings on room delete --- install/package.json | 6 +- public/language/en-GB/modules.json | 6 ++ public/openapi/components/schemas/Chats.yaml | 2 + .../read/user/userslug/chats/roomid.yaml | 8 +++ public/src/client/chats.js | 33 +++++++++ public/src/client/chats/manage.js | 24 +++++-- src/api/chats.js | 24 +++++-- src/messaging/index.js | 5 ++ src/messaging/notifications.js | 69 ++++++++++++++----- src/messaging/rooms.js | 54 +++++++++++++-- src/messaging/unread.js | 19 +++++ src/notifications.js | 2 +- src/socket.io/modules.js | 13 ++++ src/views/modals/create-room.tpl | 9 +-- src/views/modals/manage-room.tpl | 21 ++++-- 15 files changed, 249 insertions(+), 46 deletions(-) diff --git a/install/package.json b/install/package.json index e641a68e5a..7d561f8e31 100644 --- a/install/package.json +++ b/install/package.json @@ -97,14 +97,14 @@ "nodebb-plugin-emoji": "5.1.3", "nodebb-plugin-emoji-android": "4.0.0", "nodebb-plugin-markdown": "12.1.7", - "nodebb-plugin-mentions": "4.3.2", + "nodebb-plugin-mentions": "4.3.3", "nodebb-plugin-ntfy": "1.1.0", "nodebb-plugin-spam-be-gone": "2.1.1", "nodebb-rewards-essentials": "0.2.3", - "nodebb-theme-harmony": "1.1.13", + "nodebb-theme-harmony": "1.1.14", "nodebb-theme-lavender": "7.1.3", "nodebb-theme-peace": "2.1.3", - "nodebb-theme-persona": "13.2.6", + "nodebb-theme-persona": "13.2.7", "nodebb-widget-essentials": "7.0.13", "nodemailer": "6.9.4", "nprogress": "0.2.0", diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 9d114b3fbc..46ea48a225 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -36,6 +36,12 @@ "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.notification-settings": "Notification Settings", + "chat.default-notification-setting": "Default Notification Setting", + "chat.notification-setting-room-default": "Room Default", + "chat.notification-setting-none": "No notifications", + "chat.notification-setting-at-mention-only": "@mention only", + "chat.notification-setting-all-messages": "All messages", "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?", diff --git a/public/openapi/components/schemas/Chats.yaml b/public/openapi/components/schemas/Chats.yaml index 3b7383c774..3af68f754a 100644 --- a/public/openapi/components/schemas/Chats.yaml +++ b/public/openapi/components/schemas/Chats.yaml @@ -22,6 +22,8 @@ RoomObject: timestamp: type: number description: Timestamp of when room was created + notificationSetting: + type: number MessageObject: type: object properties: diff --git a/public/openapi/read/user/userslug/chats/roomid.yaml b/public/openapi/read/user/userslug/chats/roomid.yaml index a9a6ae9faf..d3eaee8e67 100644 --- a/public/openapi/read/user/userslug/chats/roomid.yaml +++ b/public/openapi/read/user/userslug/chats/roomid.yaml @@ -39,6 +39,12 @@ get: timestamp: type: number description: Timestamp of when room was created + notificationSetting: + type: number + notificationOptions: + type: array + notificationOptionsIcon: + type: string messages: type: array items: @@ -318,6 +324,8 @@ get: type: string chatWithMessage: type: string + notificationSetting: + type: number publicRooms: type: array items: diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 7307ae97bc..b329310bd8 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -99,6 +99,8 @@ define('forum/chats', [ }); userList.init(roomId, mainWrapper); Chats.addPublicRoomSortHandler(); + Chats.addTooltipHandler(); + Chats.addNotificationSettingHandler(); }; Chats.addPublicRoomSortHandler = function () { @@ -122,6 +124,37 @@ define('forum/chats', [ } }; + Chats.addTooltipHandler = function () { + $('[data-manual-tooltip]').tooltip({ + trigger: 'manual', + animation: false, + placement: 'bottom', + }).on('mouseenter', function (ev) { + const target = $(ev.target); + const isDropdown = target.hasClass('dropdown-menu') || !!target.parents('.dropdown-menu').length; + if (!isDropdown) { + $(this).tooltip('show'); + } + }).on('click mouseleave', function () { + $(this).tooltip('hide'); + }); + }; + + Chats.addNotificationSettingHandler = function () { + const notifSettingEl = $('[component="chat/notification/setting"]'); + + notifSettingEl.find('[data-value]').on('click', async function () { + notifSettingEl.find('i.fa-check').addClass('hidden'); + const $this = $(this); + $this.find('i.fa-check').removeClass('hidden'); + $('[component="chat/notification/setting/icon"]').attr('class', `fa ${$this.attr('data-icon')}`); + await socket.emit('modules.chats.setNotificationSetting', { + roomId: ajaxify.data.roomId, + value: $this.attr('data-value'), + }); + }); + }; + Chats.addUploadHandler = function (options) { uploadHelpers.init({ dragDropAreaEl: options.dragDropAreaEl, diff --git a/public/src/client/chats/manage.js b/public/src/client/chats/manage.js index 065a970606..73f33c53d3 100644 --- a/public/src/client/chats/manage.js +++ b/public/src/client/chats/manage.js @@ -23,7 +23,7 @@ define('forum/chats/manage', [ const html = await app.parseAndTranslate('modals/manage-room', { groups, user: app.user, - group: ajaxify.data, + room: ajaxify.data, }); modal = bootbox.dialog({ title: '[[modules:chat.manage-room]]', @@ -67,14 +67,28 @@ define('forum/chats/manage', [ }); }); - modal.find('[component="chat/manage/save/groups"]').on('click', (ev) => { - const btn = $(ev.target); + modal.find('[component="chat/manage/save"]').on('click', () => { + const notifSettingEl = modal.find('[component="chat/room/notification/setting"]'); api.put(`/chats/${roomId}`, { groups: modal.find('[component="chat/room/groups"]').val(), + notificationSetting: notifSettingEl.val(), }).then((payload) => { ajaxify.data.groups = payload.groups; - btn.addClass('btn-success'); - setTimeout(() => btn.removeClass('btn-success'), 1000); + ajaxify.data.notificationSetting = payload.notificationSetting; + const roomDefaultOption = payload.notificationOptions[0]; + $('[component="chat/notification/setting"] [data-icon]').first().attr( + 'data-icon', roomDefaultOption.icon + ); + $('[component="chat/notification/setting/sub-label"]').translateText( + roomDefaultOption.subLabel + ); + if (roomDefaultOption.selected) { + $('[component="chat/notification/setting/icon"]').attr( + 'class', `fa ${roomDefaultOption.icon}` + ); + } + + modal.modal('hide'); }).catch(alerts.error); }); }); diff --git a/src/api/chats.js b/src/api/chats.js index 029841648a..976c380679 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -40,11 +40,13 @@ 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]]`); } @@ -55,6 +57,11 @@ chatsAPI.create = async function (caller, data) { if (isPublic && (!Array.isArray(data.groups) || !data.groups.length)) { throw new Error('[[error:no-groups-selected]]'); } + + data.notificationSetting = isPublic ? + messaging.notificationSettings.ATMENTION : + messaging.notificationSettings.ALLMESSAGES; + await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); const roomId = await messaging.newRoom(caller.uid, data); @@ -108,18 +115,21 @@ chatsAPI.update = async (caller, data) => { }); } } + const [roomData, isAdmin] = await Promise.all([ + messaging.getRoomData(data.roomId), + user.isAdministrator(caller.uid), + ]); + if (!roomData) { + throw new Error('[[error:invalid-data]]'); + } 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)); } } + if (data.hasOwnProperty('notificationSetting') && isAdmin) { + await db.setObjectField(`chat:room:${data.roomId}`, 'notificationSetting', data.notificationSetting); + } return messaging.loadRoom(caller.uid, { roomId: data.roomId, }); diff --git a/src/messaging/index.js b/src/messaging/index.js index ec41f84cae..42cf331278 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -25,6 +25,11 @@ require('./rooms')(Messaging); require('./unread')(Messaging); require('./notifications')(Messaging); +Messaging.notificationSettings = Object.create(null); +Messaging.notificationSettings.NONE = 1; +Messaging.notificationSettings.ATMENTION = 2; +Messaging.notificationSettings.ALLMESSAGES = 3; + Messaging.messageExists = async mid => db.exists(`message:${mid}`); Messaging.getMessages = async (params) => { diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index ccde83e3f4..11a1ebd16f 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -12,6 +12,23 @@ const meta = require('../meta'); module.exports = function (Messaging) { // Only used to notify a user of a new chat message Messaging.notifyQueue = {}; + + Messaging.setUserNotificationSetting = async (uid, roomId, value) => { + if (parseInt(value, 10) === -1) { + // go back to default + return await db.deleteObjectField(`chat:room:${roomId}:notification:settings`, uid); + } + await db.setObjectField(`chat:room:${roomId}:notification:settings`, uid, parseInt(value, 10)); + }; + + Messaging.getUidsNotificationSetting = async (uids, roomId) => { + const [settings, roomData] = await Promise.all([ + db.getObjectFields(`chat:room:${roomId}:notification:settings`, uids), + Messaging.getRoomData(roomId, ['notificationSetting']), + ]); + return uids.map(uid => parseInt(settings[uid] || roomData.notificationSetting, 10)); + }; + Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObj) => { const isPublic = parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1; @@ -34,13 +51,15 @@ module.exports = function (Messaging) { // 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) { + if (messageObj.system) { return; } // push unread count only for private rooms - const uids = await Messaging.getAllUidsInRoomFromSet(`chat:room:${roomId}:uids:online`); - Messaging.pushUnreadCount(uids, unreadData); + if (!isPublic) { + const uids = await Messaging.getAllUidsInRoomFromSet(`chat:room:${roomId}:uids:online`); + Messaging.pushUnreadCount(uids, unreadData); + } // Delayed notifications let queueObj = Messaging.notifyQueue[`${fromUid}:${roomId}`]; @@ -65,27 +84,41 @@ module.exports = function (Messaging) { }; 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, - path: `/chats/${messageObj.roomId}`, - }); + fromUid = parseInt(fromUid, 10); + const [settings, roomData] = await Promise.all([ + db.getObject(`chat:room:${roomId}:notification:settings`), + Messaging.getRoomData(roomId, ['notificationSetting']), + ]); + const roomDefault = roomData.notificationSetting; + const uidsToNotify = []; + const { ALLMESSAGES } = Messaging.notificationSettings; await batch.processSortedSet(`chat:room:${roomId}:uids:online`, async (uids) => { + uids = uids.filter( + uid => (parseInt((settings && settings[uid]) || roomDefault, 10) === ALLMESSAGES) && + fromUid !== parseInt(uid, 10) + ); const hasRead = await Messaging.hasRead(uids, roomId); - uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromUid, 10) !== parseInt(uid, 10)); - - notifications.push(notification, uids); + uidsToNotify.push(...uids.filter((uid, index) => !hasRead[index])); }, { reverse: true, batch: 500, - interval: 1000, + interval: 100, }); + + if (uidsToNotify.length) { + 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, + path: `/chats/${messageObj.roomId}`, + }); + await notifications.push(notification, uidsToNotify); + } } }; diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index 2b212b7b74..c5698185ac 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -54,6 +54,15 @@ module.exports = function (Messaging) { data.groupChat = parseInt(data.groupChat, 10) === 1; } + if (!fields.length || fields.includes('notificationSetting')) { + data.notificationSetting = data.notificationSetting || + ( + data.public ? + Messaging.notificationSettings.ATMENTION : + Messaging.notificationSettings.ALLMESSAGES + ); + } + if (data.hasOwnProperty('groups') || !fields.length || fields.includes('groups')) { try { data.groups = JSON.parse(data.groups || '[]'); @@ -76,6 +85,7 @@ module.exports = function (Messaging) { const room = { roomId: roomId, timestamp: now, + notificationSetting: data.notificationSetting, }; if (data.hasOwnProperty('roomName') && data.roomName) { @@ -145,10 +155,14 @@ module.exports = function (Messaging) { ...roomIds.map(id => `chat:room:${id}:uids`), ...roomIds.map(id => `chat:room:${id}:owners`), ...roomIds.map(id => `chat:room:${id}:uids:online`), + ...roomIds.map(id => `chat:room:${id}:notification:settings`), ]), - db.sortedSetRemove('chat:rooms', roomIds), - db.sortedSetRemove('chat:rooms:public', roomIds), - db.sortedSetRemove('chat:rooms:public:order', roomIds), + db.sortedSetRemove([ + 'chat:rooms', + 'chat:rooms:public', + 'chat:rooms:public:order', + 'chat:rooms:public:lastpost', + ], roomIds), ]); cache.del([ 'chat:rooms:public:all', @@ -448,7 +462,36 @@ module.exports = function (Messaging) { await db.sortedSetAdd(`chat:room:${roomId}:uids:online`, Date.now(), uid); } - const [canReply, users, messages, settings, isOwner, onlineUids] = await Promise.all([ + async function getNotificationOptions() { + const userSetting = await db.getObjectField(`chat:room:${roomId}:notification:settings`, uid); + const roomDefault = room.notificationSetting; + const currentSetting = userSetting || roomDefault; + const labels = { + [Messaging.notificationSettings.NONE]: { label: '[[modules:chat.notification-setting-none]]', icon: 'fa-ban' }, + [Messaging.notificationSettings.ATMENTION]: { label: '[[modules:chat.notification-setting-at-mention-only]]', icon: 'fa-at' }, + [Messaging.notificationSettings.ALLMESSAGES]: { label: '[[modules:chat.notification-setting-all-messages]]', icon: 'fa-comment-o' }, + }; + const options = [ + { + label: '[[modules:chat.notification-setting-room-default]]', + subLabel: labels[roomDefault].label || '', + icon: labels[roomDefault].icon, + value: -1, + selected: userSetting === null, + }, + ]; + Object.keys(labels).forEach((key) => { + options.push({ + label: labels[key].label, + icon: labels[key].icon, + value: key, + selected: parseInt(userSetting, 10) === parseInt(key, 10), + }); + }); + return { options, selectedIcon: labels[currentSetting].icon }; + } + + const [canReply, users, messages, settings, isOwner, onlineUids, notifOptions] = await Promise.all([ Messaging.canReply(roomId, uid), Messaging.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true), Messaging.getMessages({ @@ -460,6 +503,7 @@ module.exports = function (Messaging) { user.getSettings(uid), Messaging.isRoomOwner(uid, roomId), io.getUidsInRoom(`chat_room_${roomId}`), + getNotificationOptions(), ]); users.forEach((user) => { @@ -481,6 +525,8 @@ module.exports = function (Messaging) { room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2; room.isAdminOrGlobalMod = isAdmin || isGlobalMod; room.isAdmin = isAdmin; + room.notificationOptions = notifOptions.options; + room.notificationOptionsIcon = notifOptions.selectedIcon; 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 8b98ec279d..6144def618 100644 --- a/src/messaging/unread.js +++ b/src/messaging/unread.js @@ -33,6 +33,25 @@ module.exports = function (Messaging) { }; Messaging.hasRead = async (uids, roomId) => { + if (!uids.length) { + return []; + } + const roomData = await Messaging.getRoomData(roomId); + if (!roomData) { + return uids.map(() => false); + } + if (roomData.public) { + const [userTimestamps, mids] = await Promise.all([ + db.getObjectsFields(uids.map(uid => `uid:${uid}:chat:rooms:read`), [roomId]), + db.getSortedSetRevRangeWithScores(`chat:room:${roomId}:mids`, 0, 0), + ]); + const lastMsgTimestamp = mids[0] ? mids[0].score : 0; + return uids.map( + (uid, index) => !userTimestamps[index] || + !userTimestamps[index][roomId] || + parseInt(userTimestamps[index][roomId], 10) > lastMsgTimestamp + ); + } const isMembers = await db.isMemberOfSortedSets( uids.map(uid => `uid:${uid}:chat:rooms:unread`), roomId diff --git a/src/notifications.js b/src/notifications.js index b484129d5c..b4a8aeff63 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -159,7 +159,7 @@ Notifications.push = async function (notification, uids) { winston.error(err.stack); } }); - }, 1000); + }, 500); }; async function pushToUids(uids, notification) { diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index e244892d01..7b233d7d35 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -200,4 +200,17 @@ SocketModules.chats.toggleOwner = async (socket, data) => { await Messaging.toggleOwner(data.uid, data.roomId); }; +SocketModules.chats.setNotificationSetting = async (socket, data) => { + if (!data || !utils.isNumber(data.value) || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + + const inRoom = await Messaging.isUserInRoom(socket.uid, data.roomId); + if (!inRoom) { + throw new Error('[[error:no-privileges]]'); + } + + await Messaging.setUserNotificationSetting(socket.uid, data.roomId, data.value); +}; + require('../promisify')(SocketModules); diff --git a/src/views/modals/create-room.tpl b/src/views/modals/create-room.tpl index 4df85c294d..a4b47c85a0 100644 --- a/src/views/modals/create-room.tpl +++ b/src/views/modals/create-room.tpl @@ -1,10 +1,11 @@
- - + +
+
- \ No newline at end of file