diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 07f0f4515d..9d114b3fbc 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -54,6 +54,7 @@ "chat.kick": "Kick", "chat.show-ip": "Show IP", "chat.owner": "Room Owner", + "chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.system.user-join": "%1 has joined the room", "chat.system.user-leave": "%1 has left the room", diff --git a/public/openapi/components/schemas/Chats.yaml b/public/openapi/components/schemas/Chats.yaml index 7451c93542..3b7383c774 100644 --- a/public/openapi/components/schemas/Chats.yaml +++ b/public/openapi/components/schemas/Chats.yaml @@ -1,9 +1,6 @@ RoomObject: type: object properties: - owner: - type: number - description: the uid of the chat room owner (usually the user who created the room initially) roomId: type: number description: unique identifier for the chat room @@ -143,6 +140,8 @@ RoomUserList: type: boolean canKick: type: boolean + canToggleOwner: + type: boolean index: type: number online: diff --git a/public/openapi/read/user/userslug/chats/roomid.yaml b/public/openapi/read/user/userslug/chats/roomid.yaml index e8b0876eaf..a9a6ae9faf 100644 --- a/public/openapi/read/user/userslug/chats/roomid.yaml +++ b/public/openapi/read/user/userslug/chats/roomid.yaml @@ -24,8 +24,6 @@ get: allOf: - type: object properties: - owner: - type: number roomId: type: number roomName: @@ -173,10 +171,6 @@ get: items: type: object properties: - owner: - oneOf: - - type: number - - type: string roomId: type: number roomName: diff --git a/public/src/client/chats/manage.js b/public/src/client/chats/manage.js index 5936f58211..9edb7df0c3 100644 --- a/public/src/client/chats/manage.js +++ b/public/src/client/chats/manage.js @@ -35,6 +35,7 @@ define('forum/chats/manage', [ refreshParticipantsList(roomId, modal); addKickHandler(roomId, modal); + addToggleOwnerHandler(roomId, modal); const userListEl = modal.find('[component="chat/manage/user/list"]'); const userListElSearch = modal.find('[component="chat/manage/user/list/search"]'); @@ -89,6 +90,17 @@ define('forum/chats/manage', [ }); } + function addToggleOwnerHandler(roomId, modal) { + modal.on('click', '[data-action="toggleOwner"]', async function () { + const uid = parseInt(this.getAttribute('data-uid'), 10); + const $this = $(this); + await socket.emit('modules.chats.toggleOwner', { roomId: roomId, uid: uid }); + $this.parents('[data-uid]') + .find('[component="chat/manage/user/owner/icon"]') + .toggleClass('hidden'); + }); + } + async function refreshParticipantsList(roomId, modal, data) { const listEl = modal.find('[component="chat/manage/user/list"]'); @@ -101,6 +113,7 @@ define('forum/chats/manage', [ } listEl.html(await app.parseAndTranslate('partials/chats/manage-room-users', data)); + listEl.find('[data-bs-toggle="tooltip"]').tooltip(); } return manage; diff --git a/public/src/client/chats/recent.js b/public/src/client/chats/recent.js index 68a01b62ea..a4f6490f4b 100644 --- a/public/src/client/chats/recent.js +++ b/public/src/client/chats/recent.js @@ -19,13 +19,13 @@ define('forum/chats/recent', ['alerts', 'api', 'chat'], function (alerts, api, c chat.toggleReadState(chatEl); }); - $('[component="chat/recent"]').on('scroll', function () { + $('[component="chat/recent"]').on('scroll', utils.debounce(function () { const $this = $(this); const bottom = ($this[0].scrollHeight - $this.height()) * 0.9; if ($this.scrollTop() > bottom) { loadMoreRecentChats(); } - }); + }, 100)); }); }; diff --git a/public/src/client/chats/user-list.js b/public/src/client/chats/user-list.js index 419e699032..84fd8147f1 100644 --- a/public/src/client/chats/user-list.js +++ b/public/src/client/chats/user-list.js @@ -45,6 +45,7 @@ define('forum/chats/user-list', ['api'], function (api) { if (ajaxify.data.template.chats && app.isFocused && userListEl.scrollTop() === 0 && !userListEl.hasClass('hidden')) { const data = await api.get(`/chats/${roomId}/users`, { start: 0 }); userListEl.html(await app.parseAndTranslate('partials/chats/user-list', 'users', data)); + userListEl.find('[data-bs-toggle="tooltip"]').tooltip(); } } diff --git a/src/api/chats.js b/src/api/chats.js index f7dd083355..029841648a 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -169,19 +169,22 @@ chatsAPI.users = async (caller, data) => { const start = data.hasOwnProperty('start') ? data.start : 0; const stop = start + 39; const io = require('../socket.io'); - const [isOwner, isUserInRoom, users, onlineUids] = await Promise.all([ + const [isOwner, isUserInRoom, users, isAdmin, onlineUids] = await Promise.all([ messaging.isRoomOwner(caller.uid, data.roomId), messaging.isUserInRoom(caller.uid, data.roomId), messaging.getUsersInRoomFromSet( `chat:room:${data.roomId}:uids:online`, data.roomId, start, stop, true ), + user.isAdministrator(caller.uid), io.getUidsInRoom(`chat_room_${data.roomId}`), ]); if (!isUserInRoom) { throw new Error('[[error:no-privileges]]'); } users.forEach((user) => { - user.canKick = isOwner && (parseInt(user.uid, 10) !== parseInt(caller.uid, 10)); + const isSelf = parseInt(user.uid, 10) === parseInt(caller.uid, 10); + user.canKick = isOwner && !isSelf; + user.canToggleOwner = (isAdmin || isOwner) && !isSelf; user.online = parseInt(user.uid, 10) === parseInt(caller.uid, 10) || onlineUids.includes(String(user.uid)); }); return { users }; diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index 9ab00ed7b6..bd6e3afaef 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -74,7 +74,6 @@ module.exports = function (Messaging) { const now = Date.now(); const roomId = await db.incrObjectField('global', 'nextChatRoomId'); const room = { - owner: uid, roomId: roomId, timestamp: now, }; @@ -93,6 +92,7 @@ module.exports = function (Messaging) { await Promise.all([ db.setObject(`chat:room:${roomId}`, room), db.sortedSetAdd('chat:rooms', now, roomId), + db.sortedSetAdd(`chat:room:${roomId}:owners`, now, uid), db.sortedSetsAdd([ `chat:room:${roomId}:uids`, `chat:room:${roomId}:uids:online`, @@ -143,6 +143,7 @@ module.exports = function (Messaging) { db.deleteAll([ ...roomIds.map(id => `chat:room:${id}`), ...roomIds.map(id => `chat:room:${id}:uids`), + ...roomIds.map(id => `chat:room:${id}:owners`), ...roomIds.map(id => `chat:room:${id}:uids:online`), ]), db.sortedSetRemove('chat:rooms', roomIds), @@ -207,16 +208,27 @@ module.exports = function (Messaging) { if (!isArray) { uids = [uids]; } - const owner = await db.getObjectField(`chat:room:${roomId}`, 'owner'); - const isOwners = uids.map(uid => parseInt(uid, 10) === parseInt(owner, 10)); + const isOwners = await db.isSortedSetMembers(`chat:room:${roomId}:owners`, uids); const result = await Promise.all(isOwners.map(async (isOwner, index) => { - const payload = await plugins.hooks.fire('filter:messaging.isRoomOwner', { uid: uids[index], roomId, owner, isOwner }); + const payload = await plugins.hooks.fire('filter:messaging.isRoomOwner', { uid: uids[index], roomId, isOwner }); return payload.isOwner; })); return isArray ? result : result[0]; }; + Messaging.toggleOwner = async (uid, roomId) => { + if (!(parseInt(uid, 10) > 0) || !roomId) { + return; + } + const isOwner = await Messaging.isRoomOwner(uid, roomId); + if (isOwner) { + await db.sortedSetRemove(`chat:room:${roomId}:owners`, uid); + } else { + await db.sortedSetAdd(`chat:room:${roomId}:owners`, Date.now(), uid); + } + }; + Messaging.isRoomPublic = async function (roomId) { return parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1; }; @@ -285,6 +297,7 @@ module.exports = function (Messaging) { await Promise.all([ db.sortedSetRemove([ `chat:room:${roomId}:uids`, + `chat:room:${roomId}:owners`, `chat:room:${roomId}:uids:online`, ], uids), db.sortedSetsRemove(keys, roomId), @@ -301,6 +314,7 @@ module.exports = function (Messaging) { const roomKeys = [ ...roomIds.map(roomId => `chat:room:${roomId}:uids`), + ...roomIds.map(roomId => `chat:room:${roomId}:owners`), ...roomIds.map(roomId => `chat:room:${roomId}:uids:online`), ]; await Promise.all([ @@ -319,9 +333,16 @@ module.exports = function (Messaging) { }; async function updateOwner(roomId) { - const uids = await db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, 0); - const newOwner = uids[0] || 0; - await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner); + let nextOwner = await db.getSortedSetRange(`chat:room:${roomId}:owners`, 0, 0); + if (!nextOwner[0]) { + // no owners left grab next user + nextOwner = await db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, 0); + } + + const newOwner = nextOwner[0] || 0; + if (parseInt(newOwner, 10) > 0) { + await db.sortedSetAdd(`chat:room:${roomId}:owners`, Date.now(), newOwner); + } } Messaging.getAllUidsInRoomFromSet = async function (set) { diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 559bbdd5be..e244892d01 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -110,7 +110,13 @@ async function joinLeave(socket, roomIds, method, prefix = 'chat_room') { 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)))) { + + if (isAdmin || + ( + inRooms[idx] && + (!isPublic || !roomGroups.length || await groups.isMemberOfAny(socket.uid, roomGroups)) + ) + ) { socket[method](`${prefix}_${roomId}`); } })); @@ -177,4 +183,21 @@ SocketModules.chats.searchMembers = async function (socket, data) { return { users: roomUsers }; }; +SocketModules.chats.toggleOwner = async (socket, data) => { + if (!data || !data.uid || !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 || !isRoomOwner)) { + throw new Error('[[error:no-privileges]]'); + } + + await Messaging.toggleOwner(data.uid, data.roomId); +}; + require('../promisify')(SocketModules); diff --git a/src/upgrades/3.3.0/chat_room_owners.js b/src/upgrades/3.3.0/chat_room_owners.js new file mode 100644 index 0000000000..ddb4af3865 --- /dev/null +++ b/src/upgrades/3.3.0/chat_room_owners.js @@ -0,0 +1,34 @@ +'use strict'; + + +const db = require('../../database'); +const batch = require('../../batch'); + + +module.exports = { + name: 'Create chat:room::owners zset', + timestamp: Date.UTC(2023, 6, 17), + 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 roomData = await db.getObjects( + roomIds.map(id => `chat:room:${id}`) + ); + + const bulkAdd = []; + roomData.forEach((room) => { + if (room && room.roomId && room.owner) { + bulkAdd.push([`chat:room:${room.roomId}:owners`, room.timestamp, room.owner]); + } + }); + + await db.sortedSetAddBulk(bulkAdd); + }, { + batch: 500, + }); + }, +}; diff --git a/src/views/modals/create-room.tpl b/src/views/modals/create-room.tpl index dcb470cd6e..4df85c294d 100644 --- a/src/views/modals/create-room.tpl +++ b/src/views/modals/create-room.tpl @@ -19,7 +19,7 @@ {{{ each selectedUsers }}}
  • {buildAvatar(@value, "24px", true)} {./username} - +
  • {{{ end }}} diff --git a/src/views/partials/chats/manage-room-users.tpl b/src/views/partials/chats/manage-room-users.tpl index 0c7a0c5c0f..8489cbb15b 100644 --- a/src/views/partials/chats/manage-room-users.tpl +++ b/src/views/partials/chats/manage-room-users.tpl @@ -1,12 +1,17 @@ {{{ each users }}} -
  • +
  • {buildAvatar(users, "24px", true)} - {./username}{{{ if ./isOwner }}} {{{ end }}} + {./username}
    +
    + {{{ if ./canToggleOwner }}} + + {{{ end }}} - {{{ if ./canKick }}} - - {{{ end }}} + {{{ if ./canKick }}} + + {{{ end }}} +
  • {{{ end }}} \ No newline at end of file diff --git a/test/messaging.js b/test/messaging.js index 24bbebfc19..9b07ff3670 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -226,8 +226,7 @@ describe('Messaging Library', () => { await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); const isUserInRoom = await Messaging.isUserInRoom(mocks.users.baz.uid, roomId); assert.equal(isUserInRoom, false); - const data = await Messaging.getRoomData(roomId); - assert.equal(data.owner, mocks.users.foo.uid); + assert(await Messaging.isRoomOwner(mocks.users.foo.uid, roomId)); }); it('should send a user-leave system message when a user leaves the chat room', async () => { @@ -263,8 +262,7 @@ describe('Messaging Library', () => { await callv3API('delete', `/chats/${body.response.roomId}/users/${mocks.users.herp.uid}`, {}, 'herp'); - const data = await Messaging.getRoomData(body.response.roomId); - assert.equal(data.owner, mocks.users.foo.uid); + assert(await Messaging.isRoomOwner(mocks.users.foo.uid, roomId)); }); it('should change owner if owner is deleted', async () => { @@ -284,8 +282,7 @@ describe('Messaging Library', () => { }, }); await User.deleteAccount(sender); - const data = await Messaging.getRoomData(response.roomId); - assert.equal(data.owner, receiver); + assert(await Messaging.isRoomOwner(receiver, response.roomId)); }); it('should fail to remove user from room', async () => {