mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-11-18 03:31:03 +01:00
feat: allow multiple room owners, closes #6503
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
34
src/upgrades/3.3.0/chat_room_owners.js
Normal file
34
src/upgrades/3.3.0/chat_room_owners.js
Normal file
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
|
||||
const db = require('../../database');
|
||||
const batch = require('../../batch');
|
||||
|
||||
|
||||
module.exports = {
|
||||
name: 'Create chat:room:<room_id>: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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,7 @@
|
||||
{{{ each selectedUsers }}}
|
||||
<li class="list-group-item d-flex gap-2 align-items-center justify-content-between" component="chat/user" data-uid="{./uid}">
|
||||
<a href="#" class="text-reset text-decoration-none">{buildAvatar(@value, "24px", true)} {./username}</a>
|
||||
<button component="chat/room/users/remove" class="btn btn-sm btn-link"><i class="fa fa-times text-danger"></i></button>
|
||||
<button component="chat/room/users/remove" class="btn btn-sm btn-light"><i class="fa fa-times text-danger"></i></button>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{{{ each users }}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between" data-index="{./index}">
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between" data-uid="{./uid}" data-index="{./index}">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
{buildAvatar(users, "24px", true)}
|
||||
<span>{./username}{{{ if ./isOwner }}} <i class="fa fa-star text-warning" title="[[modules:chat.owner]]"></i>{{{ end }}}</span>
|
||||
<span>{./username} <i component="chat/manage/user/owner/icon" class="fa fa-star text-warning {{{ if !./isOwner }}}hidden{{{ end }}}" title="[[modules:chat.owner]]" data-bs-toggle="tooltip"></i></span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
{{{ if ./canToggleOwner }}}
|
||||
<button class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-action="toggleOwner" data-uid="{./uid}" title="[[modules:chat.grant-rescind-ownership]]"><i class="fa fa-star text-warning"></i></button>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ if ./canKick }}}
|
||||
<button class="btn btn-sm btn-link" data-action="kick" data-uid="{./uid}">[[modules:chat.kick]]</button>
|
||||
{{{ end }}}
|
||||
{{{ if ./canKick }}}
|
||||
<button class="btn btn-sm btn-light" data-action="kick" data-uid="{./uid}" data-bs-toggle="tooltip" title="[[modules:chat.kick]]"><i class="fa fa-ban text-danger"></i></button>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user