feat: allow multiple room owners, closes #6503

isekai-main
Barış Soner Uşaklı 2 years ago
parent 4b92df75df
commit 91642cb324

@ -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);

@ -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 () => {

Loading…
Cancel
Save