diff --git a/public/openapi/read/user/userslug/chats/roomid.yaml b/public/openapi/read/user/userslug/chats/roomid.yaml index d776512925..e8b0876eaf 100644 --- a/public/openapi/read/user/userslug/chats/roomid.yaml +++ b/public/openapi/read/user/userslug/chats/roomid.yaml @@ -135,6 +135,8 @@ get: nullable: true status: type: string + online: + type: boolean icon:text: type: string description: A single-letter representation of a username. This is used in the diff --git a/public/src/client/chats.js b/public/src/client/chats.js index f1548dd282..100d24dab3 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -29,6 +29,7 @@ define('forum/chats', [ let newMessage = false; let chatNavWrapper = null; + let userListEl = null; $(window).on('action:ajaxify.start', function () { Chats.destroyAutoComplete(ajaxify.data.roomId); @@ -47,7 +48,7 @@ define('forum/chats', [ socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId)); const env = utils.findBootstrapEnvironment(); chatNavWrapper = $('[component="chat/nav-wrapper"]'); - + userListEl = $('[component="chat/user/list"]'); if (!Chats.initialised) { Chats.addSocketListeners(); Chats.addGlobalEventListeners(); @@ -468,6 +469,7 @@ define('forum/chats', [ const mainWrapper = components.get('chat/main-wrapper'); mainWrapper.html(html); chatNavWrapper = $('[component="chat/nav-wrapper"]'); + userListEl = $('[component="chat/user/list"]'); html.find('.timeago').timeago(); ajaxify.data = { ...ajaxify.data, ...payload, roomId: roomId }; ajaxify.updateTitle(ajaxify.data.title); @@ -526,6 +528,10 @@ define('forum/chats', [ Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']')); }); + socket.on('event:chats.user-online', function (data) { + userListEl.find(`[data-uid="${data.uid}"]`).toggleClass('online', !!data.state); + }); + socket.on('event:user_status_change', function (data) { app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); }); diff --git a/src/api/chats.js b/src/api/chats.js index a2d2e3467f..0fd0092bec 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -171,7 +171,9 @@ chatsAPI.users = async (caller, data) => { const [isOwner, isUserInRoom, users] = await Promise.all([ messaging.isRoomOwner(caller.uid, data.roomId), messaging.isUserInRoom(caller.uid, data.roomId), - messaging.getUsersInRoom(data.roomId, start, stop), + messaging.getUsersInRoomFromSet( + `chat:room:${data.roomId}:uids:online`, data.roomId, start, stop, true + ), ]); if (!isUserInRoom) { throw new Error('[[error:no-privileges]]'); diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index 73cefdde92..0ef05c5acd 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -39,7 +39,7 @@ module.exports = function (Messaging) { } // push unread count only for private rooms - const uids = await Messaging.getAllUidsInRoom(roomId); + const uids = await Messaging.getAllUidsInRoomFromSet(`chat:room:${roomId}:uids:online`); Messaging.pushUnreadCount(uids, unreadData); // Delayed notifications @@ -77,7 +77,7 @@ module.exports = function (Messaging) { path: `/chats/${messageObj.roomId}`, }); - await batch.processSortedSet(`chat:room:${roomId}:uids`, async (uids) => { + await batch.processSortedSet(`chat:room:${roomId}:uids:online`, async (uids) => { const hasRead = await Messaging.hasRead(uids, roomId); uids = uids.filter((uid, index) => !hasRead[index] && parseInt(fromUid, 10) !== parseInt(uid, 10)); diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index 300f928e86..9ab00ed7b6 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -10,6 +10,7 @@ const groups = require('../groups'); const plugins = require('../plugins'); const privileges = require('../privileges'); const meta = require('../meta'); +const io = require('../socket.io'); const cache = require('../cache'); const cacheCreate = require('../cacheCreate'); @@ -92,7 +93,10 @@ module.exports = function (Messaging) { await Promise.all([ db.setObject(`chat:room:${roomId}`, room), db.sortedSetAdd('chat:rooms', now, roomId), - db.sortedSetAdd(`chat:room:${roomId}:uids`, now, uid), + db.sortedSetsAdd([ + `chat:room:${roomId}:uids`, + `chat:room:${roomId}:uids:online`, + ], now, uid), ]); await Promise.all([ @@ -133,13 +137,14 @@ module.exports = function (Messaging) { .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 db.sortedSetsRemove(keys, roomId); })); await Promise.all([ - db.deleteAll(roomIds.map(id => `chat:room:${id}`)), + db.deleteAll([ + ...roomIds.map(id => `chat:room:${id}`), + ...roomIds.map(id => `chat:room:${id}:uids`), + ...roomIds.map(id => `chat:room:${id}:uids:online`), + ]), db.sortedSetRemove('chat:rooms', roomIds), db.sortedSetRemove('chat:rooms:public', roomIds), db.sortedSetRemove('chat:rooms:public:order', roomIds), @@ -193,7 +198,7 @@ module.exports = function (Messaging) { return single ? data.inRooms.pop() : data.inRooms; }; - Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}:uids`); + Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}`); Messaging.getUserCountInRoom = async roomId => db.sortedSetCard(`chat:room:${roomId}:uids`); @@ -231,7 +236,10 @@ module.exports = function (Messaging) { async function addUidsToRoom(uids, roomId) { const now = Date.now(); const timestamps = uids.map(() => now); - await db.sortedSetAdd(`chat:room:${roomId}:uids`, timestamps, uids); + await Promise.all([ + db.sortedSetAdd(`chat:room:${roomId}:uids`, timestamps, uids), + db.sortedSetAdd(`chat:room:${roomId}:uids:online`, timestamps, uids), + ]); await updateUserCount([roomId]); await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-join', uid, roomId))); } @@ -275,7 +283,10 @@ module.exports = function (Messaging) { .concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`)); await Promise.all([ - db.sortedSetRemove(`chat:room:${roomId}:uids`, uids), + db.sortedSetRemove([ + `chat:room:${roomId}:uids`, + `chat:room:${roomId}:uids:online`, + ], uids), db.sortedSetsRemove(keys, roomId), ]); @@ -288,7 +299,10 @@ module.exports = function (Messaging) { const isInRoom = await Promise.all(roomIds.map(roomId => Messaging.isUserInRoom(uid, roomId))); roomIds = roomIds.filter((roomId, index) => isInRoom[index]); - const roomKeys = roomIds.map(roomId => `chat:room:${roomId}:uids`); + const roomKeys = [ + ...roomIds.map(roomId => `chat:room:${roomId}:uids`), + ...roomIds.map(roomId => `chat:room:${roomId}:uids:online`), + ]; await Promise.all([ db.sortedSetsRemove(roomKeys, uid), db.sortedSetRemove([ @@ -310,21 +324,34 @@ module.exports = function (Messaging) { await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner); } - Messaging.getAllUidsInRoom = async function (roomId) { - const cacheKey = `chat:room:${roomId}:users`; + Messaging.getAllUidsInRoomFromSet = async function (set) { + const cacheKey = `${set}:all`; let uids = roomUidCache.get(cacheKey); if (uids !== undefined) { return uids; } - uids = await Messaging.getUidsInRoom(roomId, 0, -1); + uids = await Messaging.getUidsInRoomFromSet(set, 0, -1); roomUidCache.set(cacheKey, uids); return uids; }; - Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRange(`chat:room:${roomId}:uids`, start, stop); + Messaging.getUidsInRoomFromSet = async (set, start, stop, reverse = false) => db[ + reverse ? 'getSortedSetRevRange' : 'getSortedSetRange' + ](set, start, stop); + + Messaging.getUidsInRoom = async (roomId, start, stop, reverse = false) => db[ + reverse ? 'getSortedSetRevRange' : 'getSortedSetRange' + ](`chat:room:${roomId}:uids`, start, stop); + + Messaging.getUsersInRoom = async (roomId, start, stop, reverse = false) => { + const users = await Messaging.getUsersInRoomFromSet( + `chat:room:${roomId}:uids`, roomId, start, stop, reverse + ); + return users; + }; - Messaging.getUsersInRoom = async (roomId, start, stop) => { - const uids = await Messaging.getUidsInRoom(roomId, start, stop); + Messaging.getUsersInRoomFromSet = async (set, roomId, start, stop, reverse = false) => { + const uids = await Messaging.getUidsInRoomFromSet(set, start, stop, reverse); const [users, isOwners] = await Promise.all([ user.getUsersFields(uids, ['uid', 'username', 'picture', 'status']), Messaging.isRoomOwner(uids, roomId), @@ -373,10 +400,12 @@ module.exports = function (Messaging) { Messaging.loadRoom = async (uid, data) => { const { roomId } = data; - const [room, inRoom, canChat] = await Promise.all([ + const [room, inRoom, canChat, isAdmin, isGlobalMod] = await Promise.all([ Messaging.getRoomData(roomId), Messaging.isUserInRoom(uid, roomId), privileges.global.can('chat', uid), + user.isAdministrator(uid), + user.isGlobalModerator(uid), ]); if (!canChat) { @@ -395,23 +424,30 @@ module.exports = function (Messaging) { if (room.public && !inRoom) { await addUidsToRoom([uid], roomId); room.userCount += 1; + } else if (inRoom) { + await db.sortedSetAdd(`chat:room:${roomId}:uids:online`, Date.now(), uid); } - const [canReply, users, messages, isAdmin, isGlobalMod, settings, isOwner] = await Promise.all([ + const [canReply, users, messages, settings, isOwner, onlineUids] = await Promise.all([ Messaging.canReply(roomId, uid), - Messaging.getUsersInRoom(roomId, 0, 39), + Messaging.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true), Messaging.getMessages({ callerUid: uid, uid: data.uid || uid, roomId: roomId, isNew: false, }), - user.isAdministrator(uid), - user.isGlobalModerator(uid), user.getSettings(uid), Messaging.isRoomOwner(uid, roomId), + io.getUidsInRoom(`chat_room_${roomId}`), ]); + users.forEach((user) => { + if (user) { + user.online = parseInt(user.uid, 10) === parseInt(uid, 10) || onlineUids.includes(String(user.uid)); + } + }); + room.messages = messages; room.isOwner = isOwner; room.users = users; diff --git a/src/socket.io/index.js b/src/socket.io/index.js index b540cbc15f..858223f07d 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -292,6 +292,26 @@ Sockets.getCountInRoom = function (room) { return roomMap ? roomMap.size : 0; }; +// works across multiple nodes +Sockets.getUidsInRoom = async function (room) { + if (!Sockets.server) { + return []; + } + const ioRoom = Sockets.server.in(room); + const uids = {}; + if (ioRoom) { + const sockets = await ioRoom.fetchSockets(); + for (const s of sockets) { + for (const r of s.rooms) { + if (r.startsWith('uid_')) { + uids[r.split('_').pop()] = 1; + } + } + } + } + return Object.keys(uids); +}; + Sockets.warnDeprecated = (socket, replacement) => { if (socket.previousEvents && socket.emit) { socket.emit('event:deprecated_call', { diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 559bbdd5be..f0a71eb2fa 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -106,12 +106,18 @@ async function joinLeave(socket, roomIds, method, prefix = 'chat_room') { Messaging.isUserInRoom(socket.uid, roomIds), Messaging.getRoomsData(roomIds, ['public', 'groups']), ]); - + const io = require('./index'); await Promise.all(roomIds.map(async (roomId, idx) => { const isPublic = roomData[idx] && roomData[idx].public; const roomGroups = roomData[idx] && roomData[idx].groups; if (isAdmin || (inRooms[idx] && (!isPublic || await groups.isMemberOfAny(socket.uid, roomGroups)))) { socket[method](`${prefix}_${roomId}`); + if (prefix === 'chat_room') { + io.in(`chat_room_${roomId}`).emit('event:chats.user-online', { + uid: socket.uid, + state: method === 'join' ? 1 : 0, + }); + } } })); } diff --git a/src/upgrades/3.3.0/chat_room_online_zset.js b/src/upgrades/3.3.0/chat_room_online_zset.js new file mode 100644 index 0000000000..683730662a --- /dev/null +++ b/src/upgrades/3.3.0/chat_room_online_zset.js @@ -0,0 +1,34 @@ +'use strict'; + + +const _ = require('lodash'); + +const db = require('../../database'); +const batch = require('../../batch'); + + +module.exports = { + name: 'Create chat:room:uids:online zset', + timestamp: Date.UTC(2023, 6, 14), + method: async function () { + const { progress } = this; + + progress.total = await db.sortedSetCard('chat:rooms'); + + await batch.processSortedSet('chat:rooms', async (roomIds) => { + progress.incr(roomIds.length); + const arrayOfUids = await db.getSortedSetsMembersWithScores(roomIds.map(roomId => `chat:room:${roomId}:uids`)); + + const bulkAdd = []; + arrayOfUids.forEach((uids, idx) => { + const roomId = roomIds[idx]; + uids.forEach((uid) => { + bulkAdd.push([`chat:room:${roomId}:uids:online`, uid.score, uid.value]); + }); + }); + await db.sortedSetAddBulk(bulkAdd); + }, { + batch: 500, + }); + }, +};